amerc/src/Scene2D.jsx
artheru f063e9ae80 docs: responsive mobile layout (0.77.0)
- on phones the docs kept the desktop sidebar+content row, squeezing the prose
  into ~240px (sidebar ate the rest) — poor reading on a public docs surface
- stack on mobile (<=760px): sidebar becomes a compact scrollable doc-picker
  on top (max 210px), content goes full-width below with comfortable padding
- verified live (390px): layout column, content full-width (390 vs old 240),
  no horizontal overflow
2026-06-10 11:14:46 +08:00

480 lines
27 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, BoothDashboard } from './AgentPlatform.jsx';
import Mansion from './Mansion.jsx';
import Account from './Account.jsx';
import TavernGame from './TavernGame.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 = '0.77.0-docsmobile';
const NAV = [
{ id: 'home', label: 'Tavern' },
{ id: 'agents', label: 'Browse Agents' },
{ id: 'mansion', label: 'Mansion' },
{ id: 'booth', label: 'My Booth' },
];
const VALID_ROUTES = ['home', 'agents', 'mansion', 'booth', 'account', 'admin', 'signin', 'status', 'changelog'];
const ROUTE_TITLES = {
home: 'amerc — hire agents like mercenaries', agents: 'Browse Agents · amerc', mansion: 'The Mansion · amerc',
booth: 'My Booth · amerc', account: 'Account · amerc', signin: 'Sign in · amerc', status: 'System status · amerc',
changelog: 'Changelog · amerc', admin: 'Quartermaster · amerc',
};
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];
}
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" aria-label="amerc 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 }) {
const [stats, setStats] = useState(null);
const [scrolled, setScrolled] = useState(false);
const homeRef = useRef(null);
useEffect(() => { api('/agents/stats').then(setStats).catch(() => {}); }, []);
useEffect(() => {
const el = homeRef.current; if (!el) return;
const onScroll = () => setScrolled(el.scrollTop > 500);
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, []);
return (
<div className="gm-home" ref={homeRef}>
<div className="gm-hero">
<div className="gm-stage"><TavernGame onHotspot={setRoute} stats={stats} /></div>
<div className="gm-caption">
<h1>Hire agents like mercenaries.</h1>
<p>Hire, run, and deliver AI agents across any software boundary as an embedded chatbox, a live web terminal, or a public URL. <b>No infrastructure, just skills.</b></p>
<div className="gm-cta">
<button className="gm-cta-btn gm-cta-primary" onClick={() => setRoute('agents')}>Browse Agents </button>
<button className="gm-cta-btn gm-cta-secondary" onClick={() => setRoute('booth')}>Publish yours</button>
</div>
{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 className="gm-scrollcue" onClick={() => document.querySelector('.hf')?.scrollIntoView({ behavior: 'smooth' })}>how it works </div>
</div>
</div>
<HomeFeatures setRoute={setRoute} />
<Footer setRoute={setRoute} />
<button className={`gm-totop${scrolled ? ' show' : ''}`} onClick={() => homeRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} aria-label="Back to top" type="button"></button>
</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 Booth and run instances — your broker brings the runtime. No infrastructure to manage.', cta: ['Open My Booth', 'booth'] },
{ 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: ['The 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>
);
}
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>
<ActivityFeed />
<div className="hf-cta">
<button className="px-action" onClick={() => setRoute('agents')}>Browse Agents</button>
<button className="px-action" onClick={() => setRoute('booth')}>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"><h5>Product</h5>
<button onClick={() => setRoute('agents')}>Browse Agents</button>
<button onClick={() => setRoute('mansion')}>Mansion</button>
<button onClick={() => setRoute('booth')}>My Booth</button>
</div>
<div className="ft-col"><h5>Resources</h5>
<a href="https://docs.amerc.ai/">Docs</a>
<button onClick={() => setRoute('changelog')}>Changelog</button>
</div>
<div className="ft-col"><h5>Platform</h5>
<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">OPEN BOOTH</span><h2>Welcome, mercenary</h2><p>Log in, or create your account in seconds.</p></header>
<AuthPanel auth={auth} onDone={() => setRoute('booth')} />
</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: '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 === 'booth') return <Page setRoute={setRoute}><BoothDashboard 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 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>
);
}