resilience: app-wide error boundary (0.64.0)

- previously, any render error would unmount the whole SPA to a blank white
  screen (no boundary)
- add a React error boundary around the routed app (all sub-apps: tavern,
  docs, pm) with an on-brand fallback — gem, 'Something went off-script',
  Reload + System status links — instead of a void
- verified the catch path locally (a temporary ?boom trigger rendered the
  fallback), confirmed the boundary is transparent in normal use, then
  removed the trigger; verified live renders normally
This commit is contained in:
artheru 2026-06-10 08:55:46 +08:00
parent 53331a40d4
commit 74edbb3e11
5 changed files with 43 additions and 6 deletions

View File

@ -4,7 +4,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0e0a14" /> <meta name="theme-color" content="#0e0a14" />
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." /> <meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
<meta name="version" content="0.63.0-docsaccuracy" /> <meta name="version" content="0.64.0-resilience" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" /> <link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />

View File

@ -1,6 +1,6 @@
{ {
"name": "amerc-site", "name": "amerc-site",
"version": "0.63.0-docsaccuracy", "version": "0.64.0-resilience",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -7,7 +7,7 @@ import Account from './Account.jsx';
import TavernGame from './TavernGame.jsx'; import TavernGame from './TavernGame.jsx';
import { api } from './api.js'; import { api } from './api.js';
const RELEASE = '0.63.0-docsaccuracy'; const RELEASE = '0.64.0-resilience';
const NAV = [ const NAV = [
{ id: 'home', label: 'Tavern' }, { id: 'home', label: 'Tavern' },

View File

@ -12,11 +12,37 @@ let App = Scene2DApp;
if (host.startsWith('docs.')) App = DocsApp; if (host.startsWith('docs.')) App = DocsApp;
else if (host.startsWith('pm.')) App = PmApp; else if (host.startsWith('pm.')) App = PmApp;
// Catch render errors so a single bad component can't blank the whole app.
class ErrorBoundary extends React.Component {
constructor(props) { super(props); this.state = { error: null }; }
static getDerivedStateFromError(error) { return { error }; }
render() {
if (this.state.error) {
return (
<div className="eb">
<div className="eb-card">
<span className="eb-gem" aria-hidden="true" />
<h1>Something went off-script.</h1>
<p>The tavern hit an unexpected snag. A reload usually sorts it out.</p>
<div className="eb-actions">
<button type="button" onClick={() => window.location.reload()}>Reload</button>
<a href="https://amerc.ai/#/status">System status</a>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<Suspense fallback={<div style={{ color: '#9fb0d0', fontFamily: 'monospace', padding: 24 }}>Loading amerc</div>}> <ErrorBoundary>
<App /> <Suspense fallback={<div style={{ color: '#9fb0d0', fontFamily: 'monospace', padding: 24 }}>Loading amerc</div>}>
</Suspense> <App />
</Suspense>
</ErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@ -1435,3 +1435,14 @@ button {
/* themed task-list checkboxes in docs (0.63) */ /* themed task-list checkboxes in docs (0.63) */
.docs-render input[type="checkbox"] { accent-color: var(--accent); width: 14px; height: 14px; vertical-align: -2px; margin-right: 6px; } .docs-render input[type="checkbox"] { accent-color: var(--accent); width: 14px; height: 14px; vertical-align: -2px; margin-right: 6px; }
.docs-render li:has(> input[type="checkbox"]) { list-style: none; margin-left: -20px; } .docs-render li:has(> input[type="checkbox"]) { list-style: none; margin-left: -20px; }
/* error boundary fallback (0.64) */
.eb { position: fixed; inset: 0; display: grid; place-items: center; background: radial-gradient(circle at 50% 28%, #160d22, #07060c 70%); padding: 24px; }
.eb-card { max-width: 420px; text-align: center; background: linear-gradient(180deg, #160f24, #110b1d); border: 1px solid #2a1f3e; border-radius: 16px; padding: 32px 28px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); font-family: Georgia, "Times New Roman", serif; }
.eb-gem { width: 26px; height: 26px; display: inline-block; background: linear-gradient(135deg, #5fe6ff, #1f86c4); transform: rotate(45deg); box-shadow: 0 0 0 3px #07273a, 0 0 24px rgba(39,216,255,0.5); margin-bottom: 20px; }
.eb-card h1 { color: #f3d27a; font-size: 22px; margin: 0 0 10px; }
.eb-card p { color: #b6a7d4; font-size: 14px; line-height: 1.6; margin: 0 0 22px; }
.eb-actions { display: flex; gap: 12px; justify-content: center; align-items: center; }
.eb-actions button { font-family: Georgia, serif; font-size: 14px; padding: 9px 22px; border-radius: 10px; border: none; cursor: pointer; background: linear-gradient(180deg, #f3d27a, #d9a948); color: #2a1303; font-weight: 600; }
.eb-actions a { font-family: Georgia, serif; font-size: 14px; color: #cfe6ff; text-decoration: none; }
.eb-actions a:hover { color: #7fe9ff; }