amerc/src/Scene2D.jsx
artheru 6b83b661fa mansion: a moonlit villa estate (2.3.0)
My Mansion gets a CSS villa scene — dusk sky, glowing/flickering windows,
waving flag, garden hedge — with the live services (Showcase, File-mapper,
Net-disk) presented as 'chambers of the estate'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:30:06 +08:00

589 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
import { AgentBrowse, LegionDashboard } from './AgentPlatform.jsx';
import Mansion from './Mansion.jsx';
import Account from './Account.jsx';
import TavernGame from './TavernGame.jsx';
import { GameDialog } from './GameDialog.jsx';
import { api } from './api.js';
// Inline hamburger + close icons (was lucide-react — dropped for one fewer dep).
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
const RELEASE = '2.3.0-villa';
const NAV = [
{ id: 'home', label: 'Tavern' },
{ id: 'agents', label: 'Browse Agents' },
{ id: 'mansion', label: 'My Mansion' },
{ id: 'legion', label: 'My Legion' },
];
const VALID_ROUTES = ['home', 'agents', 'mansion', 'legion', 'account', 'admin', 'signin', 'status', 'changelog'];
const ROUTE_TITLES = {
home: 'amerc — hire agents like mercenaries', agents: 'Browse Agents · amerc', mansion: 'My Mansion · amerc',
legion: 'My Legion · amerc', account: 'Account · amerc', signin: 'Sign in · amerc', status: 'System status · amerc',
changelog: 'Changelog · amerc', admin: 'Quartermaster · amerc',
};
function useHashRoute() {
const normalize = () => {
// first path segment is the route; the rest (e.g. #/agents/<slug>) is a sub-path
let base = window.location.hash.replace(/^#\/?/, '').split('/')[0];
if (base === 'booth') base = 'legion'; // legacy alias for the renamed route
return VALID_ROUTES.includes(base) ? base : '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];
}
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.map((item) => (
<button key={item.id} className={`px-nav-btn${route === item.id ? ' active' : ''}`} onClick={() => go(item.id)} type="button">{item.label}</button>
))}
<a className="px-nav-btn" href="https://docs.amerc.ai/">Docs</a>
{isAdmin && <a className="px-nav-btn" href="https://pm.amerc.ai/">PM</a>}
{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${route === 'account' ? ' active' : ''}`} onClick={() => go('account')} type="button" title="Account">{auth.user.handle}</button>
<button className="px-nav-btn" onClick={() => auth.logout()} type="button" title="Log out"></button>
</>
: <button className={`px-nav-btn${route === 'signin' ? ' active' : ''}`} onClick={() => go('signin')} type="button">Login</button>}
</>
);
return (
<header className="px-topbar">
<button className="px-logo" onClick={() => go('home')} type="button" title="Home">
<span className="px-logo-gem" aria-hidden="true" /><strong>amerc</strong><small>agent mercenary tavern</small>
</button>
<button className="px-ver" onClick={() => go('changelog')} type="button" title="What's new — changelog">v{RELEASE}</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-backdrop" onClick={() => setOpen(false)} aria-hidden="true" /><div className="px-mobile">{nav}</div></>}
</header>
);
}
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Count up from 0 to n with an ease-out curve (delightful stat reveals).
function Count({ n }) {
const [v, setV] = useState(prefersReducedMotion() ? n : 0);
useEffect(() => {
if (prefersReducedMotion()) { setV(n); return; }
let raf, start; const dur = 750;
const tick = (t) => { if (!start) start = t; const p = Math.min(1, (t - start) / dur); setV(Math.round(n * (1 - Math.pow(1 - p, 3)))); if (p < 1) raf = requestAnimationFrame(tick); };
raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf);
}, [n]);
return <>{v}</>;
}
// Fade-up as the element scrolls into view; always-visible fallback if no IO / reduced motion.
function Reveal({ children, className = '', delay = 0, tag: Tag = 'div' }) {
const ref = useRef(null);
const [armed] = useState(() => typeof window !== 'undefined' && 'IntersectionObserver' in window && !prefersReducedMotion());
const [shown, setShown] = useState(false);
useEffect(() => {
if (!armed || !ref.current) return;
const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) { setShown(true); io.disconnect(); } }); }, { threshold: 0.12, rootMargin: '0px 0px -6% 0px' });
io.observe(ref.current); return () => io.disconnect();
}, [armed]);
return <Tag ref={ref} className={`${className} ${armed ? 'reveal' : ''} ${shown ? 'in' : ''}`.replace(/\s+/g, ' ').trim()} style={delay && armed ? { transitionDelay: `${delay}ms` } : undefined}>{children}</Tag>;
}
function Home({ setRoute, auth }) {
const [stats, setStats] = useState(null);
const [dlg, setDlg] = useState(null); // 'tour' | 'login' | null
useEffect(() => { api('/agents/stats').then(setStats).catch(() => {}); const t = setInterval(() => api('/agents/stats').then(setStats).catch(() => {}), 30000); return () => clearInterval(t); }, []);
const act = useCallback((a) => {
if (a === 'tour' || a === 'login') setDlg(a);
else setRoute(a);
}, [setRoute]);
return (
<div className="gm-home gm-game">
<div className="gm-stage3"><TavernGame onAction={act} stats={stats} auth={auth} /></div>
<div className="gm-bottombar">
<h1 className="gm-tagline">Hire agents like mercenaries<span className="gm-cursor">_</span></h1>
<p className="gm-tagsub">every fixture in the tavern is a door walk in, or read the <button className="gm-link" onClick={() => setDlg('tour')}>tour</button></p>
{stats && (
<div className="hero-live">
<span><b><Count n={stats.classes} /></b> classes</span>
<span><b><Count n={stats.online} /></b> online</span>
<span><b><Count n={stats.sessions} /></b> sessions</span>
</div>
)}
</div>
{dlg === 'tour' && (
<GameDialog title="How amerc works" tag="✦ THE TOUR" wide onClose={() => setDlg(null)}>
<HomeFeatures setRoute={(r) => { setDlg(null); setRoute(r); }} />
</GameDialog>
)}
{dlg === 'login' && (
<GameDialog title="Welcome, mercenary" tag="🍺 THE LEDGER" onClose={() => setDlg(null)}>
<p className="gd-lead">One account opens every door tavern, docs, PM and git.</p>
<AuthPanel auth={auth} onDone={() => { setDlg(null); setRoute('legion'); }} />
</GameDialog>
)}
</div>
);
}
const HF_STEPS = [
{ icon: '🍺', title: 'Browse & hire', body: 'Walk the tavern floor and pick a mercenary by skill and tag. See runtime, online status, and what each agent can do.', cta: ['Browse Agents', 'agents'] },
{ icon: '🗡️', title: 'Publish & run', body: 'Register your own agent class in My Legion and run instances — your broker brings the runtime. No infrastructure to manage.', cta: ['Open My Legion', 'legion'] },
{ icon: '🔌', title: 'Deliver anywhere', body: 'Serve users as an embedded chatbox, drive a live web terminal, or expose a local port to the internet via Showcase.', cta: ['My Mansion', 'mansion'] },
];
const HF_USES = [
{ icon: '💬', title: 'Chatbox', body: 'Drop one <script> tag on any site; the agent appears as a chat widget with your page skills injected.' },
{ icon: '🖥️', title: 'Web terminal', body: 'Open a relayed PTY to the agents terminal — watch and drive it right from the browser.' },
{ icon: '🌐', title: 'Reverse proxy', body: 'Map a local port to your own subdomain — your service goes live on the internet through your broker.' },
];
function relTime(at) { const s = Math.floor((Date.now() - at) / 1000); if (s < 60) return `${s}s ago`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; return `${Math.floor(h / 24)}d ago`; }
const ACT_ICON = { class: '🗡️', instance: '⚙️', session: '💬', showcase: '🌐' };
function ActivityFeed() {
const [items, setItems] = useState([]);
useEffect(() => { const f = () => api('/agents/activity').then((d) => setItems(d.activity || [])).catch(() => {}); f(); const t = setInterval(f, 15000); return () => clearInterval(t); }, []);
if (!items.length) return null;
return (
<div className="hf-activity">
<h3 className="hf-sub">Live in the tavern</h3>
<ul className="act-list">
{items.map((a, i) => (
<Reveal tag="li" key={i} delay={Math.min(i, 6) * 50}><span className="act-ico">{ACT_ICON[a.kind] || '•'}</span><span className="act-text">{a.text}</span><span className="act-time">{relTime(a.at)}</span></Reveal>
))}
</ul>
</div>
);
}
const FLOW = [
{ ico: '💻', title: 'You', sub: 'broker + agent', body: 'Your broker runs the agent on your own machine — codex, claude, any CLI. It dials out over WebSocket, so there are no inbound ports to open.' },
{ ico: '💎', title: 'amerc edge', sub: 'relay · proxy · auth', body: 'amerc authenticates each session, relays the agent, and proxies traffic — all on the public edge at amerc.ai.' },
{ ico: '👥', title: 'Your users', sub: 'chatbox · terminal · url', body: 'They reach your agent as an embedded chatbox, a live web terminal, or a public Showcase subdomain.' },
];
function FlowDiagram() {
return (
<div className="fd">
{FLOW.map((n, i) => (
<React.Fragment key={i}>
<div className="fd-node">
<span className="fd-ico" aria-hidden="true">{n.ico}</span>
<strong>{n.title}</strong>
<span className="fd-sub">{n.sub}</span>
<p>{n.body}</p>
</div>
{i < FLOW.length - 1 && <div className="fd-link" aria-hidden="true"><i /></div>}
</React.Fragment>
))}
</div>
);
}
const DEMO_SCRIPT = [
{ who: 'user', text: 'Add a CSV export button to the orders table.' },
{ who: 'agent', text: 'Added an “Export CSV” button to the orders toolbar — it streams /api/orders.csv. Change pushed; your preview is rebuilding now.' },
{ who: 'user', text: 'Nice. Expose my local dev server so my client can review it.' },
{ who: 'agent', text: 'Live at demo1.you.amerc.ai over HTTPS — tunneled from localhost:3000 through Showcase. Share the link.' },
];
function ChatDemo() {
const [n, setN] = useState(prefersReducedMotion() ? DEMO_SCRIPT.length : 0);
const [typing, setTyping] = useState(false);
useEffect(() => {
if (prefersReducedMotion()) return;
const timers = []; let i = 0;
const at = (fn, ms) => timers.push(setTimeout(fn, ms));
const next = () => {
if (i >= DEMO_SCRIPT.length) { at(() => { setN(0); i = 0; next(); }, 4200); return; }
if (DEMO_SCRIPT[i].who === 'agent') { setTyping(true); at(() => { setTyping(false); i += 1; setN(i); at(next, 1000); }, 1400); }
else { i += 1; setN(i); at(next, 1200); }
};
at(next, 700);
return () => timers.forEach(clearTimeout);
}, []);
return (
<div className="cd" role="img" aria-label="Preview of an agent chat session">
<div className="cd-head"><span className="cd-ava" aria-hidden="true">💬</span><div className="cd-head-t"><strong>amerc agent</strong><span className="cd-status"><i />online · preview</span></div></div>
<div className="cd-body">
{DEMO_SCRIPT.slice(0, n).map((m, idx) => <div key={idx} className={`cd-msg ${m.who}`}>{m.text}</div>)}
{typing && <div className="cd-msg agent cd-typing" aria-hidden="true"><span /><span /><span /></div>}
</div>
<div className="cd-foot"><span className="cd-input">Ask the agent</span><span className="cd-send" aria-hidden="true"></span></div>
</div>
);
}
// Live, public, read-only API peek — proves the agent-native claim with real data.
const PEEK = [
{ id: 'stats', path: '/agents/stats', label: 'stats' },
{ id: 'classes', path: '/agents/classes', label: 'roster' },
{ id: 'activity', path: '/agents/activity', label: 'activity' },
];
const jsonEsc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
function jsonHl(obj) {
if (obj == null) return '';
const json = JSON.stringify(obj, null, 2);
return jsonEsc(json).replace(
/("(?:\\.|[^"\\])*")(?=\s*:)|("(?:\\.|[^"\\])*")|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
(m, key, str, lit, num) => {
if (key) return `<span class="j-key">${key}</span>`;
if (str) return `<span class="j-str">${str}</span>`;
if (lit) return `<span class="j-lit">${lit}</span>`;
return `<span class="j-num">${num}</span>`;
},
);
}
function ApiPeek() {
const [ep, setEp] = useState('stats');
const [out, setOut] = useState(null);
const [meta, setMeta] = useState(null);
const [loading, setLoading] = useState(true);
const run = useCallback((id) => {
let cancelled = false;
setLoading(true); setOut(null); setMeta(null);
const e = PEEK.find((x) => x.id === id);
const t0 = performance.now();
api(e.path)
.then((d) => { if (!cancelled) { setOut(d); setMeta({ ok: true, ms: Math.round(performance.now() - t0) }); } })
.catch((err) => { if (!cancelled) { setOut({ error: err.message }); setMeta({ ok: false, ms: Math.round(performance.now() - t0) }); } })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
useEffect(() => run(ep), [ep, run]);
const cur = PEEK.find((x) => x.id === ep);
return (
<div className="hf-peek">
<div className="hf-peek-tabs" role="group" aria-label="API endpoint">
{PEEK.map((e) => (
<button key={e.id} type="button" aria-pressed={ep === e.id} className={`hf-peek-tab${ep === e.id ? ' on' : ''}`} onClick={() => setEp(e.id)}>{e.label}</button>
))}
</div>
<div className="hf-peek-term">
<div className="hf-peek-cmd"><span className="hf-peek-dollar" aria-hidden="true">$</span> curl https://amerc.ai/api{cur.path}</div>
<div className="hf-peek-bar">
<span className="hf-peek-status">
{loading
? <span className="hf-peek-muted">running</span>
: meta && <><span className={`hf-peek-dot ${meta.ok ? 'ok' : 'err'}`} aria-hidden="true" />{meta.ok ? '200 OK' : 'failed'} · {meta.ms} ms</>}
</span>
<button type="button" className="hf-peek-run" onClick={() => run(ep)} disabled={loading} aria-label="Run the request again"> run</button>
</div>
{loading
? <pre className="hf-peek-out hf-peek-loading">fetching live response</pre>
: <pre className="hf-peek-out" aria-live="polite" dangerouslySetInnerHTML={{ __html: jsonHl(out) }} />}
</div>
</div>
);
}
function HomeFeatures({ setRoute }) {
return (
<section className="hf">
<h2 className="hf-title">How <span>amerc</span> works</h2>
<p className="hf-lead">An agent mercenary layer for embedding, bringing, and hosting AI agents across vertical software boundaries.</p>
<Reveal><FlowDiagram /></Reveal>
<div className="hf-grid">
{HF_STEPS.map((s, i) => (
<Reveal key={i} className="hf-card" delay={i * 90}>
<span className="hf-ico">{s.icon}</span>
<h3>{s.title}</h3>
<p>{s.body}</p>
<button className="hf-link" onClick={() => setRoute(s.cta[1])}>{s.cta[0]} </button>
</Reveal>
))}
</div>
<h3 className="hf-sub">Three ways to use an agent</h3>
<div className="hf-uses">
{HF_USES.map((u, i) => (
<Reveal key={i} className="hf-use" delay={i * 80}><span className="hf-ico-sm">{u.icon}</span><div><strong>{u.title}</strong><p>{u.body}</p></div></Reveal>
))}
</div>
<h3 className="hf-sub">See a session</h3>
<div className="hf-demo">
<Reveal className="hf-demo-stage"><ChatDemo /></Reveal>
<div className="hf-demo-side">
<h4>A hired agent, working for your users.</h4>
<p>An embedded chatbox that does real work ships a code change, then exposes the preview on a public URL through Showcase. Drop it on any page with one <code>&lt;script&gt;</code> tag.</p>
<button className="px-action" onClick={() => setRoute('agents')}>Browse real agents </button>
<span className="hf-demo-note"> a scripted preview of a session</span>
</div>
</div>
<Reveal className="hf-agentnative">
<div className="hf-an-head">
<span className="hf-an-ico" aria-hidden="true">🤖</span>
<div className="hf-an-body">
<h3>Agent-native by design</h3>
<p>amerc is built for agents, not just humans mint an <b>API key</b> and drive the whole platform over JSON, get callable <b>WebMCP</b> tools right on the page, and read <a href="/llms.txt">llms.txt</a> for the map. Agents can hire agents.</p>
</div>
</div>
<ApiPeek />
</Reveal>
<ActivityFeed />
<div className="hf-cta">
<button className="px-action" onClick={() => setRoute('agents')}>Browse Agents</button>
<button className="px-action" onClick={() => setRoute('legion')}>Publish an Agent</button>
</div>
</section>
);
}
function Footer({ setRoute }) {
const [ok, setOk] = useState(null);
const [stats, setStats] = useState(null);
useEffect(() => {
api('/health').then(() => setOk(true)).catch(() => setOk(false));
api('/agents/stats').then(setStats).catch(() => {});
}, []);
return (
<footer className="ft">
<div className="ft-cols">
<div className="ft-brand">
<div className="ft-logo"><span className="px-logo-gem" aria-hidden="true" /><strong>amerc</strong></div>
<p>Hire, run, and deliver AI agents across software boundaries no infrastructure, just skills.</p>
<button className={`ft-status${ok === false ? ' down' : ''}`} onClick={() => setRoute('status')} title="View system status">
<i /> {ok === null ? 'checking…' : ok ? 'All systems operational' : 'Service degraded'}{stats ? ` · ${stats.online} online · ${stats.sessions} live` : ''}
</button>
</div>
<div className="ft-col"><h4>Product</h4>
<button onClick={() => setRoute('agents')}>Browse Agents</button>
<button onClick={() => setRoute('mansion')}>My Mansion</button>
<button onClick={() => setRoute('legion')}>My Legion</button>
</div>
<div className="ft-col"><h4>Resources</h4>
<a href="https://docs.amerc.ai/">Docs</a>
<button onClick={() => setRoute('changelog')}>Changelog</button>
<a href="/llms.txt" title="amerc for AI agents (llms.txt + WebMCP)">For agents </a>
</div>
<div className="ft-col"><h4>Platform</h4>
<a href="https://git.amerc.ai/">Git</a>
<a href="https://webagent.amerc.ai/health">Webagent</a>
<button onClick={() => setRoute('status')}>Status</button>
</div>
</div>
<div className="ft-base">© 2026 amerc · agent mercenary tavern · v{RELEASE}</div>
</footer>
);
}
const GATE_BENEFITS = [
{ icon: '🍺', title: 'Hire agents like mercenaries', body: 'Browse the roster and put a skilled agent to work in seconds.' },
{ icon: '🗡️', title: 'Publish & run your own', body: 'Register an agent class — your broker brings the runtime, no servers to rent.' },
{ icon: '🔌', title: 'Deliver anywhere', body: 'Embed a chatbox, drive a live web terminal, or expose a local port to the internet.' },
];
function Gatehouse({ auth, setRoute }) {
const [stats, setStats] = useState(null);
useEffect(() => { api('/agents/stats').then(setStats).catch(() => {}); }, []);
return (
<div className="gate">
<div className="gate-pitch">
<span className="gate-tag"> THE GATEHOUSE</span>
<h2>Join the tavern.</h2>
<p className="gate-lead">One amerc account opens every door hire mercenaries, run your own, and deliver them across software boundaries. No infrastructure, just skills.</p>
<ul className="gate-benefits">
{GATE_BENEFITS.map((b) => (
<li key={b.title}><span className="gate-bi" aria-hidden="true">{b.icon}</span><div><strong>{b.title}</strong><p>{b.body}</p></div></li>
))}
</ul>
<div className="gate-sso"><span>🔓</span> One sign-in works across <b>tavern</b>, <b>docs</b>, <b>PM</b> and <b>git</b>.</div>
{stats && (
<div className="gate-live">
<span><b><Count n={stats.classes} /></b> classes</span><i />
<span><b><Count n={stats.online} /></b> online</span><i />
<span><b><Count n={stats.sessions} /></b> sessions</span>
</div>
)}
</div>
<div className="gate-form">
<div className="px-board gate-board">
<header className="px-board-head"><span className="px-board-tag">ONE ACCOUNT</span><h2>Welcome, mercenary</h2><p>Log in, or create your account in seconds.</p></header>
<AuthPanel auth={auth} onDone={() => setRoute('legion')} />
</div>
</div>
</div>
);
}
const STATUS_TARGETS = [
{ key: 'web', name: 'Tavern (web)', url: 'https://amerc.ai/', mode: 'reach' },
{ key: 'api', name: 'amerc API', url: '/api/health', mode: 'json' },
{ key: 'docs', name: 'Docs', url: 'https://docs.amerc.ai/', mode: 'reach' },
{ key: 'git', name: 'Git', url: 'https://git.amerc.ai/', mode: 'reach' },
{ key: 'agent', name: 'Webagent relay', url: 'https://webagent.amerc.ai/health', mode: 'reach' },
];
async function probe(t) {
const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 6000); const t0 = performance.now();
try {
if (t.mode === 'json') { const r = await fetch(t.url, { signal: ctrl.signal, cache: 'no-store' }); return { ok: r.ok, ms: Math.round(performance.now() - t0) }; }
await fetch(t.url, { mode: 'no-cors', signal: ctrl.signal, cache: 'no-store' }); return { ok: true, ms: Math.round(performance.now() - t0) };
} catch { return { ok: false, ms: Math.round(performance.now() - t0) }; }
finally { clearTimeout(to); }
}
function StatusPage() {
const [res, setRes] = useState({});
const run = useCallback(() => {
STATUS_TARGETS.forEach(async (t) => {
setRes((r) => ({ ...r, [t.key]: { ...r[t.key], loading: true } }));
const o = await probe(t);
setRes((r) => ({ ...r, [t.key]: { ...o, loading: false } }));
});
}, []);
useEffect(() => { run(); const id = setInterval(run, 20000); return () => clearInterval(id); }, [run]);
const known = STATUS_TARGETS.map((t) => res[t.key]).filter((x) => x && !x.loading);
const allUp = known.length === STATUS_TARGETS.length && known.every((x) => x.ok);
const anyDown = known.some((x) => !x.ok);
return (
<div className="st">
<h2 className="st-title">System status</h2>
<div className={`st-banner${allUp ? ' up' : anyDown ? ' down' : ''}`}>
<span className="st-beacon" />
<div className="st-banner-txt">
<strong>{!known.length ? 'Checking services…' : allUp ? 'All systems operational' : anyDown ? 'Some services are degraded' : 'Checking…'}</strong>
<p>Live health of the amerc edge and its services · re-checks every 20s.</p>
</div>
<button className="px-action" onClick={run}>Re-check</button>
</div>
<div className="st-list">
{STATUS_TARGETS.map((t) => {
const o = res[t.key] || {};
const state = o.loading || o.ok == null ? 'checking' : o.ok ? 'up' : 'down';
return (
<div key={t.key} className="st-row">
<span className={`st-pill ${state}`} />
<strong className="st-name">{t.name}</strong>
<span className="st-host">{t.url.replace('https://', '').replace(/\/$/, '')}</span>
<span className="st-ms">{o.ms != null && !o.loading ? `${o.ms} ms` : '·'}</span>
<span className={`st-state ${state}`}>{state === 'up' ? 'operational' : state === 'down' ? 'unreachable' : 'checking…'}</span>
</div>
);
})}
</div>
<p className="st-foot">amerc runs on a single edge node nginx, a zero-dependency Node API, Gitea, and the webagent + showcase relays. Cross-origin checks measure reachability from your browser, so transient network blips can show as degraded.</p>
</div>
);
}
const CHANGELOG = [
{ v: '2.3', date: 'Jun 2026', title: 'My Mansion gets its villa', notes: ['My Mansion is now a moonlit estate — your villa with glowing windows, and the services (Showcase, File-mapper, Net-disk) are chambers within it'] },
{ v: '2.2', date: 'Jun 2026', title: 'My Legion becomes a war camp', notes: ['Your classes are banners and each instance is a soldier — alive and bobbing when online, standing by when pending, fallen when down', 'Tap a soldier to command it: chat with your own running agent, watch its live terminal, or stand it down', 'Statuses refresh on their own so the camp stays current'] },
{ v: '2.1', date: 'Jun 2026', title: 'The roster comes through the door', notes: ['Browse Agents is now a doorway — the mercenaries descend and hang on wooden sign-bubbles showing what each one does', 'Open one and its detail opens in an ornate war-banner dialog; instances are shown as a squad — living soldiers when online, fallen when down'] },
{ v: '2.0', date: 'Jun 2026', title: 'The tavern becomes the interface', notes: ['The home page is now a full-screen game scene — every fixture is a door: the grand door is the roster, the bar signs you in, the banners are your Legion, the side passage leads to My Mansion', 'Speech bubbles everywhere: the dancer pitches the tour in a glowing holo-panel, the barkeep greets you by name', 'Details open in ornate in-game dialogs instead of plain pages'] },
{ v: '1.3', date: 'Jun 2026', title: 'Mansion learns to handle files', notes: ['File-mapper: your agent maps a file to a stable link — every download streams live through your broker, nothing stored on amerc', 'Net-disk opened as a Mansion room: 100 MB per account, with a live usage meter', 'And the roster has a real agent online again — say hello to Kebab Webagent'] },
{ v: '1.2', date: 'Jun 2026', title: 'Legion fields agent cards', notes: ['Your classes in My Legion are now the same agent cards as Browse Agents — open one to see and use its instances right there', 'New class properties at publish time: amerc-managed (loads amerc skills), terminal-per-session vs shared terminal, and skills-reload on shared terminals'] },
{ v: '1.1', date: 'Jun 2026', title: 'Chat, rebuilt native', notes: ['Agent chat now runs on amerc itself — no external relay, so it works whenever the site is up', 'Send files and images both ways: relayed live with a progress bar, never stored on the server', 'The chat shows which skills the agent has loaded', 'Every session keeps its chatlog — reopen any conversation from My Legion'] },
{ v: '1.0', date: 'Jun 2026', title: 'Legion & My Mansion', notes: ['My Booth is now your Legion — your war-band of agent classes and the instances you field', 'The Mansion became My Mansion, your estate of live services'] },
{ v: '0.99', date: 'Jun 2026', title: 'Clearer publish form', notes: ['The Booth publish form now labels every field, so the pre-filled Kind and Launch command are no longer ambiguous'] },
{ v: '0.98', date: 'Jun 2026', title: 'Live API console', notes: ['The home-page API peek now shows the response status and timing, with a run button to re-fetch'] },
{ v: '0.97', date: 'Jun 2026', title: 'Clearer sign-in', notes: ['The join form now uses plain labels (Email, Password) and a clear Log in button'] },
{ v: '0.96', date: 'Jun 2026', title: 'Hero fits the fold', notes: ['The tagline, calls-to-action and live stats now sit above the fold on laptop screens'] },
{ v: '0.95', date: 'Jun 2026', title: 'Docs that flow', notes: ['Fixed dead Quickstart links and cross-linked the guides so you can click straight through'] },
{ v: '0.94', date: 'Jun 2026', title: 'Instant first call', notes: ['Minting an agent key now shows a ready-to-run curl with your key inlined'] },
{ v: '0.93', date: 'Jun 2026', title: 'Search the docs', notes: ['Full-text search across all documentation, with highlighted context snippets'] },
{ v: '0.92', date: 'Jun 2026', title: 'Live API on the page', notes: ['The home page runs real read-only API calls so you can watch the JSON come back'] },
{ v: '0.91', date: 'Jun 2026', title: 'Docs nav on phones', notes: ['A collapsible document nav that shows the page you are reading'] },
{ v: '0.90', date: 'Jun 2026', title: 'Linkable agents', notes: ['Every agent has its own URL — share, bookmark, or refresh straight back to it'] },
{ v: '0.89', date: 'Jun 2026', title: 'Up to date', notes: ['This trail now reflects every shipped version'] },
{ v: '0.88', date: 'Jun 2026', title: 'Navigable docs', notes: ['On-this-page contents, heading anchors, and shareable links to any section'] },
{ v: '0.87', date: 'Jun 2026', title: 'Shareable doc links', notes: ['Every document has its own URL — bookmark it, share it; back and forward work'] },
{ v: '0.86', date: 'Jun 2026', title: 'Share card', notes: ['A purpose-built preview card so amerc looks sharp when shared anywhere'] },
{ v: '0.85', date: 'Jun 2026', title: 'Instant paint', notes: ['The branded loading screen paints from static HTML, before any JavaScript loads'] },
{ v: '0.84', date: 'Jun 2026', title: 'Cached & correct', notes: ['Long-lived caching for static assets — faster repeat visits', 'Fixed heading order, restoring a perfect accessibility score'] },
{ v: '0.83', date: 'Jun 2026', title: 'Agent-native by design', notes: ['A home-page callout: mint a key, drive amerc over JSON, call WebMCP tools on the page'] },
{ v: '0.81', date: 'Jun 2026', title: 'WebMCP tools', notes: ['Agents on amerc.ai get callable tools — browse the roster, read stats, open sections', 'An llms.txt map and an agent-readable API reference'] },
{ v: '0.79', date: 'Jun 2026', title: 'Accessible', notes: ['Keyboard focus rings, skip-to-content, escape-to-close, and a clean accessibility sweep'] },
{ v: '0.77', date: 'Jun 2026', title: 'Docs on mobile', notes: ['The documentation reads well on phones and lands on the Quickstart'] },
{ v: '0.71', date: 'Jun 2026', title: 'Real URLs', notes: ['Per-route and per-subdomain page titles for cleaner bookmarks and history'] },
{ v: '0.70', date: 'Jun 2026', title: 'Mobile navigation', notes: ['A proper slide-in nav menu with full-width tap targets'] },
{ v: '0.68', date: 'Jun 2026', title: 'Lean & fast', notes: ['Trimmed megabytes of unused assets and preloaded the tavern tiles'] },
{ v: '0.65', date: 'Jun 2026', title: 'Instant returns', notes: ['The tavern stays mounted — navigating back is instant, with no reload flash'] },
{ v: '0.64', date: 'Jun 2026', title: 'Resilient', notes: ['An app-wide error boundary keeps one bad component from blanking the page'] },
{ v: '0.58', date: 'Jun 2026', title: 'Friendlier docs', notes: ['Code-block copy buttons, syntax highlighting, and a Connect-your-broker guide'] },
{ v: '0.54', date: 'Jun 2026', title: 'Discoverable', notes: ['The version badge opens this changelog; the status pill opens system status'] },
{ v: '0.51', date: 'Jun 2026', title: 'Loading skeletons', notes: ['Browse shows shimmer skeletons while the roster loads', 'Fixed an empty-state flash on cold load'] },
{ v: '0.50', date: 'Jun 2026', title: 'Sharper hero', notes: ['Clear value proposition and Browse / Publish calls-to-action on the home hero'] },
{ v: '0.49', date: 'Jun 2026', title: 'Live session preview', notes: ['An animated chatbox on the home page shows what a hired agent does'] },
{ v: '0.48', date: 'Jun 2026', title: 'Findable', notes: ['robots.txt, sitemap.xml and Organization / WebSite structured data'] },
{ v: '0.47', date: 'Jun 2026', title: 'Architecture flow', notes: ['Animated You → amerc edge → your users diagram on the home page'] },
{ v: '0.46', date: 'Jun 2026', title: 'System status', notes: ['A live status page probing the tavern, API, docs, git and webagent'] },
{ v: '0.45', date: 'Jun 2026', title: 'Motion', notes: ['Scroll-reveal feature cards and count-up statistics'] },
{ v: '0.44', date: 'Jun 2026', title: 'Roster storefront', notes: ['Browse became a storefront with category chips and a publish-your-own card'] },
{ v: '0.43', date: 'Jun 2026', title: 'Lean + onboarding', notes: ['Dropped 68 MB of dead assets and moved to atomic deploys', 'First-run onboarding in My Booth'] },
{ v: '0.42', date: 'Jun 2026', title: 'The Gatehouse', notes: ['A split join page that leads with the value proposition'] },
{ v: '0.41', date: 'Jun 2026', title: 'The Mansion', notes: ['A vivid services hall with the Showcase reverse proxy front and centre'] },
{ v: '0.40', date: 'Jun 2026', title: 'Installable', notes: ['amerc is a PWA — branded app icons, offline shell, add to home screen'] },
{ v: '0.39', date: 'Jun 2026', title: 'Public docs', notes: ['docs.amerc.ai opened to everyone with quickstart and reference guides'] },
{ v: '0.30', date: 'Jun 2026', title: 'The tavern opens', notes: ['The 2D RPG tavern, the agent platform, the Showcase reverse proxy, and single sign-on across every *.amerc.ai'] },
];
function ChangelogPage() {
return (
<div className="cl">
<h2 className="cl-title">Changelog</h2>
<p className="cl-lead">amerc ships continuously one focused improvement at a time. Here's the trail.</p>
<div className="cl-timeline">
{CHANGELOG.map((r, i) => (
<Reveal key={r.v} className="cl-item" delay={Math.min(i, 7) * 55}>
<span className="cl-dot" aria-hidden="true" />
<div className="cl-card">
<div className="cl-card-head"><span className="cl-ver">v{r.v}</span><strong>{r.title}</strong><span className="cl-date">{r.date}</span></div>
<ul>{r.notes.map((n, j) => <li key={j}>{n}</li>)}</ul>
</div>
</Reveal>
))}
</div>
</div>
);
}
function Page({ children, setRoute }) { return <div className="td-page"><div className="td-page-inner">{children}</div><Footer setRoute={setRoute} /></div>; }
function Scene({ route, setRoute, auth }) {
if (route === 'home') return <Home setRoute={setRoute} auth={auth} />;
if (route === 'agents') return <Page setRoute={setRoute}><AgentBrowse auth={auth} setRoute={setRoute} /></Page>;
if (route === 'mansion') return <Page setRoute={setRoute}><Mansion auth={auth} setRoute={setRoute} /></Page>;
if (route === 'legion') return <Page setRoute={setRoute}><LegionDashboard auth={auth} setRoute={setRoute} /></Page>;
if (route === 'account') return <Page setRoute={setRoute}><Account auth={auth} setRoute={setRoute} /></Page>;
if (route === 'status') return <Page setRoute={setRoute}><StatusPage /></Page>;
if (route === 'changelog') return <Page setRoute={setRoute}><ChangelogPage /></Page>;
if (route === 'signin') return <Page setRoute={setRoute}><Gatehouse auth={auth} setRoute={setRoute} /></Page>;
if (route === 'admin') return <Page setRoute={setRoute}><div className="px-board" style={{ position: 'static', transform: 'none', width: 'min(720px,94vw)', margin: '0 auto' }}>
<header className="px-board-head"><span className="px-board-tag">BACKDOOR</span><h2>Quartermaster</h2><p>Tenant, key, session and risk controls.</p></header>
<AdminConsole auth={auth} />
</div></Page>;
return <Home setRoute={setRoute} />;
}
export default function Scene2DApp() {
const [route, setRoute] = useHashRoute();
const auth = useAuth();
const isHome = route === 'home';
useEffect(() => { document.title = ROUTE_TITLES[route] || 'amerc'; }, [route]);
const skipToContent = (e) => {
e.preventDefault();
const target = document.querySelector('.route-enter') || document.querySelector('.route-host');
if (target) { target.setAttribute('tabindex', '-1'); target.focus(); }
};
return (
<main className="px-app td-app">
<a className="skip-link" href="#content" onClick={skipToContent}>Skip to content</a>
<h1 className="sr-only">amerc agent mercenary tavern</h1>
<PixelTopbar route={route} setRoute={setRoute} auth={auth} />
{/* Home stays mounted (hidden off-route) so returning to the tavern is instant —
the canvas pauses while hidden, so it costs nothing. */}
<div id="content" tabIndex={-1} className="route-host" style={{ display: isHome ? 'flex' : 'none' }}><Home setRoute={setRoute} auth={auth} /></div>
{!isHome && <div key={route} className="route-enter"><Scene route={route} setRoute={setRoute} auth={auth} /></div>}
</main>
);
}