Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

error-page.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

error-page.tsxBlame651 lines · 1 contributor
c63b860Claude1/**
48c9efcClaude2 * BLOCK O2 — Shared error page surface (404 / 500 / 403 / 429).
c63b860Claude3 *
48c9efcClaude4 * One JSX component renders every error surface consistently. Used by:
5 * - `app.notFound` (global 404 fall-through)
6 * - `app.onError` (global 500 catcher — preserves request ID + trace)
7 * - `requireAdmin` middleware (403, via the DB-free standalone renderer)
8 * - Future per-route 429 / 403 helpers (`RateLimitPage`, `ForbiddenPage`)
c63b860Claude9 *
48c9efcClaude10 * Visual recipe (2026 polish — matches admin-integrations / build-agent-spec):
11 * - Gradient hairline strip across the top (purple→cyan, 2px)
12 * - Soft radial orb in the corner (blurred, low-opacity)
13 * - Large display headline using clamp() + the gradient-text utility
14 * - Plain-English subtitle (no jargon, no stack traces in production)
15 * - Real button links — Home / Explore / Help / Search, picked per code
16 * - Request ID surfaced on 500 so support can trace the failure
17 * - Retry-after timing on 429 so the user knows when to come back
18 *
19 * Hard rules honoured:
20 * - NO database access (must render when DB is down)
21 * - Reuses existing tokens via Layout (--accent, --bg-elevated, etc.)
22 * - New CSS scoped under `.err-*` (legacy `.error-page-*` classes kept
23 * on the same elements only where existing tests assert against them)
24 * - Dark + light theme both via the same gradient-text utility
25 * - Accessible: role="main", aria-labelledby, polite live region
c63b860Claude26 */
27
28import type { FC } from "hono/jsx";
29import type { User } from "../db/schema";
30import { Layout } from "./layout";
31
48c9efcClaude32export interface ErrorPageSuggestion { href: string; label: string; hint?: string }
c63b860Claude33export interface ErrorPageProps {
34 code: string;
35 eyebrow: string;
36 title: string;
37 body: string;
38 requestId?: string;
39 user?: User | null;
40 suggestions?: ErrorPageSuggestion[];
41 primaryCta?: { href: string; label: string };
42 secondaryCta?: { href: string; label: string };
43 meta?: string;
44 trace?: string;
45 layoutTitle?: string;
48c9efcClaude46 /** Optional helper paragraph rendered between body + actions. */
47 note?: string;
48 /** Optional retry-after seconds (used by the 429 surface). */
49 retryAfterSeconds?: number;
c63b860Claude50}
51
52export const ErrorPage: FC<ErrorPageProps> = ({
53 code, eyebrow, title, body, requestId, user, suggestions,
48c9efcClaude54 primaryCta, secondaryCta, meta, trace, layoutTitle, note, retryAfterSeconds,
c63b860Claude55}) => {
56 const primary = primaryCta ?? { href: "/", label: "Go home" };
57 const secondary = secondaryCta ?? { href: "/status", label: "Status page" };
58 return (
59 <Layout title={layoutTitle ?? `${code} ${eyebrow}`} user={user ?? null}>
48c9efcClaude60 <main
61 class="err-page error-page"
62 role="main"
63 aria-labelledby="error-page-title"
64 data-error-code={code}
65 >
66 <div class="err-hero">
67 <div class="err-orb" aria-hidden="true" />
68 <div class="err-hero-inner">
69 <div class="err-eyebrow">
70 <span class="err-eyebrow-dot" aria-hidden="true" />
71 {eyebrow}
72 </div>
73 <div class="err-code gradient-text error-page-code" aria-hidden="true">
74 {code}
75 </div>
76 <h1 id="error-page-title" class="err-title">{title}</h1>
77 <p class="err-body">{body}</p>
78 {note && <p class="err-note">{note}</p>}
79 <div class="err-actions">
80 <a href={primary.href} class="err-btn err-btn-primary" data-error-cta="primary">
81 {primary.label}
82 </a>
83 <a href={secondary.href} class="err-btn err-btn-ghost" data-error-cta="secondary">
84 {secondary.label}
85 </a>
86 </div>
87 {suggestions && suggestions.length > 0 && (
88 <ul class="err-suggestions" aria-label="Try one of these">
89 {suggestions.map((s) => (
90 <li>
91 <a href={s.href}>
92 <span class="err-suggestion-label">{s.label}</span>
93 {s.hint && <span class="err-suggestion-hint">{s.hint}</span>}
94 <span class="err-suggestion-arrow" aria-hidden="true">&rarr;</span>
95 </a>
96 </li>
97 ))}
98 </ul>
99 )}
100 <div class="err-meta-block">
101 {requestId && (
102 <div class="err-meta" aria-live="polite">
103 <span class="err-meta-label">Request ID:</span>
104 <span class="err-meta-value meta-mono">{requestId}</span>
105 </div>
106 )}
107 {typeof retryAfterSeconds === "number" && retryAfterSeconds > 0 && (
108 <div class="err-meta">
109 <span class="err-meta-label">Retry after:</span>
110 <span class="err-meta-value meta-mono">
111 {formatRetryAfter(retryAfterSeconds)}
112 </span>
113 </div>
114 )}
115 {meta && (
116 <div class="err-meta">
117 <span class="err-meta-label">Request:</span>
118 <span class="err-meta-value meta-mono">{meta}</span>
119 </div>
120 )}
121 </div>
122 {trace && (
123 <pre class="err-trace error-page-trace" aria-label="Stack trace">
124 {trace}
125 </pre>
126 )}
c63b860Claude127 </div>
48c9efcClaude128 </div>
c63b860Claude129 </main>
130 <style dangerouslySetInnerHTML={{ __html: errorPageCss }} />
131 </Layout>
132 );
133};
134
48c9efcClaude135/* ─────────────────────────────────────────────────────────────────────────
136 * 404 — Not found. The most common error the user will see.
137 *
138 * For paths shaped like `/:owner/:repo/...` we add a contextual note: a real
139 * class of bugs has landed users on a 404 when they typed `ccantynz-alt`
140 * instead of `ccantynz`. Cheap to suggest "double-check the owner/name".
141 * ───────────────────────────────────────────────────────────────────── */
142export const NotFoundPage: FC<{ user?: User | null; method?: string; path?: string }> = ({ user, method, path }) => {
143 const looksLikeRepoPath =
144 typeof path === "string" &&
145 /^\/[^/]+\/[^/]+(\/.*)?$/.test(path) &&
146 !path.startsWith("/api/") &&
147 !path.startsWith("/admin/") &&
148 !path.startsWith("/static/");
149 const note = looksLikeRepoPath
150 ? "Looks like a repository URL. Double-check the owner and repo name — a one-character typo (think `ccantynz` vs `ccantynz-alt`) is the most common reason this page shows up."
151 : undefined;
152 const suggestions: ErrorPageSuggestion[] = [
153 { href: "/explore", label: "Explore public repositories", hint: "browse what's trending" },
154 { href: "/search", label: "Search across Gluecron", hint: "code, repos, issues, PRs" },
155 { href: "/help", label: "Read the quickstart guide", hint: "5-minute tour" },
156 ];
157 return (
158 <ErrorPage
159 code="404"
160 eyebrow="Not found"
161 title="We can't find that page."
162 body="The URL might be wrong, the resource might have moved, or you might not have permission to see it."
163 user={user}
164 note={note}
165 primaryCta={{ href: "/", label: "Go home" }}
166 secondaryCta={{ href: "/status", label: "Status page" }}
167 suggestions={suggestions}
168 meta={method && path ? `${method} ${path}` : undefined}
169 layoutTitle="Not Found"
170 />
171 );
172};
c63b860Claude173
48c9efcClaude174/* ─────────────────────────────────────────────────────────────────────────
175 * 500 — Server error. We surface the Request ID so the user can include it
176 * when filing a support issue, and (outside production) the error message
177 * for fast local debugging. NEVER show a full stack trace in production.
178 * ───────────────────────────────────────────────────────────────────── */
c63b860Claude179export const ServerErrorPage: FC<{ user?: User | null; requestId?: string; trace?: string }> = ({ user, requestId, trace }) => (
180 <ErrorPage
181 code="500"
182 eyebrow="Server error"
183 title="Something went wrong on our end."
48c9efcClaude184 body="The error has been reported and the team has been paged. Try again in a moment — if it persists, include the Request ID below when you file an issue."
c63b860Claude185 user={user}
186 primaryCta={{ href: "/", label: "Go home" }}
48c9efcClaude187 secondaryCta={{ href: "/status", label: "Check status" }}
188 suggestions={[
189 { href: "/status", label: "Platform status", hint: "live health of every service" },
190 { href: "/help", label: "Contact support", hint: "include the Request ID" },
191 ]}
c63b860Claude192 requestId={requestId}
193 trace={trace}
194 layoutTitle="Error"
195 />
196);
197
48c9efcClaude198/* ─────────────────────────────────────────────────────────────────────────
199 * 403 — Forbidden. Two flavours: signed-in (need a different account or
200 * elevated role) vs signed-out (just sign in). Default copy matches the
201 * admin gate so the existing `requireAdmin` middleware looks consistent.
202 * ───────────────────────────────────────────────────────────────────── */
c63b860Claude203export const ForbiddenPage: FC<{ user?: User | null; message?: string }> = ({ user, message }) => {
204 const suggestions: ErrorPageSuggestion[] = user
48c9efcClaude205 ? [
206 { href: "/logout", label: "Sign in as a different user", hint: "switch accounts" },
207 { href: "/help", label: "Read the access docs", hint: "permissions & teams" },
208 ]
209 : [
210 { href: "/login", label: "Sign in", hint: "you might just need to log in" },
211 { href: "/help", label: "Read the access docs", hint: "permissions & teams" },
212 ];
c63b860Claude213 return (
214 <ErrorPage
215 code="403"
216 eyebrow="Forbidden"
217 title={message ?? "Admin access required."}
218 body="You're signed in, but this resource is restricted. If you think this is a mistake, contact a site admin."
219 user={user}
220 primaryCta={{ href: "/", label: "Go home" }}
48c9efcClaude221 secondaryCta={{ href: "/help", label: "Get help" }}
c63b860Claude222 suggestions={suggestions}
223 layoutTitle="Forbidden"
224 />
225 );
226};
227
48c9efcClaude228/* ─────────────────────────────────────────────────────────────────────────
229 * 429 — Too many requests. The rate-limit middleware currently returns a
230 * JSON payload (API-shaped), but we expose this surface for any HTML
231 * route that wants to render a friendly throttling page. Surfaces the
232 * Retry-After timing so the user knows when to come back.
233 * ───────────────────────────────────────────────────────────────────── */
234export const RateLimitPage: FC<{ user?: User | null; retryAfterSeconds?: number; requestId?: string }> = ({ user, retryAfterSeconds, requestId }) => (
235 <ErrorPage
236 code="429"
237 eyebrow="Too many requests"
238 title="You're going a little too fast."
239 body="We rate-limit certain endpoints to keep things responsive for everyone. The bucket refills in a few seconds — wait it out and try again."
240 user={user}
241 note="If you're hitting this from a script, add a polite delay between calls. Authenticated requests get 4× the anonymous bucket."
242 primaryCta={{ href: "/", label: "Go home" }}
243 secondaryCta={{ href: "/help", label: "Read the rate-limit docs" }}
244 suggestions={[
245 { href: "/help", label: "Rate-limit reference", hint: "current caps per endpoint" },
246 { href: "/settings/tokens", label: "Create a personal access token", hint: "authed callers get a bigger bucket" },
247 ]}
248 requestId={requestId}
249 retryAfterSeconds={retryAfterSeconds}
250 layoutTitle="Rate limited"
251 />
252);
253
254/* ─────────────────────────────────────────────────────────────────────────
255 * Scoped CSS — every new class prefixed `.err-*` so it can't bleed into
256 * other surfaces. Two legacy classes (`.error-page-code`, `.error-page-trace`)
257 * remain on the same elements so the existing system-states tests keep
258 * passing; they just inherit the new visual treatment via .err-* parents.
259 *
260 * Design tokens reused from the layout:
261 * --accent (#8c6dff), --accent-2 (#36c5d6), --accent-gradient,
262 * --bg-elevated, --border, --border-strong, --border-focus,
263 * --text, --text-strong, --text-muted, --space-*, --font-display,
264 * --font-mono. The gradient-text utility comes from the layout too.
265 * ───────────────────────────────────────────────────────────────────── */
c63b860Claude266export const errorPageCss = `
48c9efcClaude267 .err-page {
268 max-width: 760px;
269 margin: var(--space-8, 56px) auto var(--space-16, 96px);
270 padding: 0 var(--space-4, 24px);
271 }
272
273 .err-hero {
274 position: relative;
275 padding: clamp(28px, 4vw, 56px) clamp(24px, 4vw, 56px) clamp(32px, 4vw, 56px);
276 background: var(--bg-elevated);
277 border: 1px solid var(--border);
278 border-radius: 20px;
279 overflow: hidden;
280 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 20px 48px -12px rgba(0,0,0,0.45);
281 }
282 /* Gradient hairline strip across the top — the signature 2026 motif. */
283 .err-hero::before {
284 content: '';
285 position: absolute;
286 top: 0; left: 0; right: 0;
287 height: 2px;
288 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
289 opacity: 0.75;
290 pointer-events: none;
291 }
292 /* Soft radial orb — blurred, low opacity, off the top-right corner.
293 Adds atmosphere without competing with the headline. */
294 .err-orb {
295 position: absolute;
296 inset: -30% -15% auto auto;
297 width: 460px; height: 460px;
298 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
299 filter: blur(80px);
300 opacity: 0.75;
301 pointer-events: none;
302 z-index: 0;
303 }
304 .err-hero-inner { position: relative; z-index: 1; }
305
306 .err-eyebrow {
307 display: inline-flex;
308 align-items: center;
309 gap: 8px;
310 text-transform: uppercase;
311 font-family: var(--font-mono);
312 font-size: 11px;
313 letter-spacing: 0.18em;
314 color: var(--text-muted);
315 font-weight: 600;
316 margin-bottom: 18px;
317 }
318 .err-eyebrow-dot {
319 width: 8px; height: 8px;
320 border-radius: 9999px;
321 background: linear-gradient(135deg, #8c6dff, #36c5d6);
322 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
323 }
324
325 /* The display code — 404, 500, etc. */
326 .err-code {
327 font-family: var(--font-display);
328 font-size: clamp(56px, 8vw, 96px);
329 line-height: 0.95;
330 font-weight: 800;
331 letter-spacing: -0.04em;
332 margin: 0 0 12px;
333 /* Fallback colour for browsers that don't paint -webkit-text-fill-color
334 on background-clip:text. The .gradient-text utility (defined in the
335 layout) applies the gradient on top. */
336 color: var(--accent);
337 }
338
339 .err-title {
340 font-family: var(--font-display);
341 font-size: clamp(22px, 3vw, 30px);
342 font-weight: 700;
343 letter-spacing: -0.022em;
344 line-height: 1.2;
345 margin: 0 0 12px;
346 color: var(--text-strong);
347 }
348 .err-body {
349 font-size: 16px;
350 line-height: 1.6;
351 color: var(--text-muted);
352 max-width: 560px;
353 margin: 0 0 16px;
354 }
355 .err-note {
356 font-size: 14px;
357 line-height: 1.55;
358 color: var(--text);
359 max-width: 600px;
360 margin: 0 0 22px;
361 padding: 12px 14px;
362 background: rgba(140,109,255,0.06);
363 border: 1px solid rgba(140,109,255,0.22);
364 border-radius: 10px;
365 }
366
367 /* Real button links — primary + ghost. We deliberately don't reuse
368 .btn from the layout because it's redefined in a dozen places; a
369 local pair keeps the error surface visually self-contained. */
370 .err-actions {
371 display: flex;
372 gap: 10px;
373 flex-wrap: wrap;
374 margin: 22px 0 28px;
375 }
376 .err-btn {
377 display: inline-flex;
378 align-items: center;
379 justify-content: center;
380 padding: 11px 20px;
381 border-radius: 10px;
382 font-size: 14px;
383 font-weight: 600;
384 text-decoration: none;
385 border: 1px solid transparent;
386 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
387 line-height: 1;
388 }
389 .err-btn-primary {
390 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
391 color: #ffffff;
392 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
393 }
394 .err-btn-primary:hover {
395 transform: translateY(-1px);
396 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
397 color: #ffffff;
398 text-decoration: none;
399 }
400 .err-btn-ghost {
401 background: transparent;
402 color: var(--text);
403 border-color: var(--border-strong, var(--border));
404 }
405 .err-btn-ghost:hover {
406 background: rgba(140,109,255,0.06);
407 border-color: rgba(140,109,255,0.45);
408 color: var(--text-strong);
409 text-decoration: none;
410 }
411
412 /* Suggestion list — each row is a full-width link with hover tint. */
413 .err-suggestions {
414 list-style: none;
415 padding: 0;
416 margin: 0 0 24px;
417 border-top: 1px solid var(--border);
418 }
419 .err-suggestions li { margin: 0; }
420 .err-suggestions a {
421 display: flex;
422 align-items: baseline;
423 gap: 10px;
424 padding: 12px 4px;
425 border-bottom: 1px solid var(--border);
426 color: var(--text);
427 text-decoration: none;
428 transition: padding-left 120ms ease, color 120ms ease;
429 }
430 .err-suggestions a:hover {
431 padding-left: 8px;
432 color: var(--text-strong);
433 text-decoration: none;
434 }
435 .err-suggestion-label {
436 font-size: 14.5px;
437 font-weight: 600;
438 color: var(--text-strong);
439 }
440 .err-suggestion-hint {
441 flex: 1;
442 font-size: 12.5px;
443 color: var(--text-muted);
444 }
445 .err-suggestion-arrow {
446 font-size: 14px;
447 color: var(--accent);
448 transition: transform 120ms ease;
449 }
450 .err-suggestions a:hover .err-suggestion-arrow { transform: translateX(3px); }
451
452 .err-meta-block {
453 display: flex;
454 flex-direction: column;
455 gap: 8px;
456 margin-top: 4px;
457 }
458 .err-meta {
459 display: inline-flex;
460 align-items: center;
461 gap: 10px;
462 font-size: 12.5px;
463 color: var(--text-muted);
464 flex-wrap: wrap;
465 }
466 .err-meta-label {
467 text-transform: uppercase;
468 letter-spacing: 0.14em;
469 font-size: 10.5px;
470 font-weight: 700;
471 color: var(--text-muted);
472 }
473 .err-meta-value,
474 .err-page .meta-mono {
475 font-family: var(--font-mono);
476 background: rgba(255,255,255,0.04);
477 border: 1px solid var(--border);
478 border-radius: 6px;
479 padding: 3px 8px;
480 font-size: 12px;
481 color: var(--text);
482 }
483
484 .err-trace {
485 text-align: left;
486 margin: 22px 0 0;
487 padding: 14px 16px;
488 background: rgba(0,0,0,0.28);
489 border: 1px solid var(--border);
490 border-radius: 10px;
491 font-family: var(--font-mono);
492 font-size: 12px;
493 line-height: 1.55;
494 color: var(--text-muted);
495 overflow-x: auto;
496 max-width: 100%;
497 white-space: pre-wrap;
498 }
499
500 /* Light-theme adjustments — orb + note tint look heavy on white. */
501 :root[data-theme='light'] .err-hero {
502 box-shadow: 0 1px 0 rgba(0,0,0,0.02), 0 12px 36px -10px rgba(15,16,28,0.10);
503 }
504 :root[data-theme='light'] .err-orb {
505 background: radial-gradient(circle, rgba(109,77,255,0.16), rgba(8,145,178,0.08) 45%, transparent 70%);
506 }
507 :root[data-theme='light'] .err-meta-value,
508 :root[data-theme='light'] .err-page .meta-mono {
509 background: rgba(15,16,28,0.04);
510 }
511 :root[data-theme='light'] .err-trace {
512 background: rgba(15,16,28,0.04);
513 }
514
515 @media (max-width: 540px) {
516 .err-page { margin: 24px auto 56px; }
517 .err-hero { padding: 24px 20px 28px; border-radius: 16px; }
518 .err-actions .err-btn { flex: 1 1 auto; }
519 }
c63b860Claude520`;
521
48c9efcClaude522function formatRetryAfter(seconds: number): string {
523 if (seconds < 60) return `${Math.ceil(seconds)}s`;
524 const m = Math.floor(seconds / 60);
525 const s = Math.ceil(seconds % 60);
526 return s > 0 ? `${m}m ${s}s` : `${m}m`;
527}
528
529/* ─────────────────────────────────────────────────────────────────────────
530 * Standalone (DB-free) renderer — used by `requireAdmin` middleware so
531 * the 403 page renders even when the DB / Layout is unavailable. Returns
532 * a complete <!doctype html> document with inline styles. Mirrors the
533 * `.err-*` visual treatment so the 403 doesn't look like a different
534 * product than the rest of the surface.
535 * ───────────────────────────────────────────────────────────────────── */
c63b860Claude536export function renderStandaloneErrorPage(opts: {
537 code: string; eyebrow: string; title: string; body: string;
538 requestId?: string; signedIn?: boolean;
539}): string {
540 const { code, eyebrow, title, body, requestId, signedIn } = opts;
541 const suggestionsHtml = signedIn
48c9efcClaude542 ? `<li><a href="/logout"><span class="err-suggestion-label">Sign in as a different user</span><span class="err-suggestion-hint">switch accounts</span><span class="err-suggestion-arrow" aria-hidden="true">&rarr;</span></a></li>
543 <li><a href="/help"><span class="err-suggestion-label">Read the access docs</span><span class="err-suggestion-hint">permissions &amp; teams</span><span class="err-suggestion-arrow" aria-hidden="true">&rarr;</span></a></li>`
544 : `<li><a href="/login"><span class="err-suggestion-label">Sign in</span><span class="err-suggestion-hint">you might just need to log in</span><span class="err-suggestion-arrow" aria-hidden="true">&rarr;</span></a></li>
545 <li><a href="/help"><span class="err-suggestion-label">Read the access docs</span><span class="err-suggestion-hint">permissions &amp; teams</span><span class="err-suggestion-arrow" aria-hidden="true">&rarr;</span></a></li>`;
c63b860Claude546 const requestIdHtml = requestId
48c9efcClaude547 ? `<div class="err-meta-block"><div class="err-meta" aria-live="polite"><span class="err-meta-label">Request ID:</span><span class="err-meta-value meta-mono">${escapeHtml(requestId)}</span></div></div>`
c63b860Claude548 : "";
549 return `<!doctype html>
550<html lang="en" data-theme="dark">
551<head>
552<meta charset="UTF-8">
553<meta name="viewport" content="width=device-width, initial-scale=1.0">
554<title>${escapeHtml(code)} ${escapeHtml(eyebrow)}</title>
555<style>
48c9efcClaude556:root {
557 --bg:#0a0b14; --bg-elevated:#0f111a; --text:#e6edf3; --text-strong:#f7f7fb; --text-muted:#8b949e;
558 --border:rgba(255,255,255,0.08); --border-strong:rgba(255,255,255,0.13);
559 --accent:#8c6dff; --accent-2:#36c5d6;
560 --font-display:'Inter Tight','Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
561 --font-mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,Consolas,monospace;
562}
563html,body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; margin:0; padding:0; min-height:100vh; }
564body::before {
565 content:''; position:fixed; inset:0; pointer-events:none; z-index:0;
566 background:
567 radial-gradient(70% 55% at 85% -20%, rgba(140,109,255,0.07), transparent 65%),
568 radial-gradient(55% 45% at -10% 115%, rgba(54,197,214,0.05), transparent 65%);
569}
570.err-page { position:relative; z-index:1; max-width:760px; margin:56px auto 96px; padding:0 24px; }
571.err-hero {
572 position:relative; padding:clamp(28px,4vw,56px) clamp(24px,4vw,56px) clamp(32px,4vw,56px);
573 background:var(--bg-elevated); border:1px solid var(--border); border-radius:20px; overflow:hidden;
574 box-shadow:0 1px 0 rgba(255,255,255,0.04), 0 20px 48px -12px rgba(0,0,0,0.45);
575}
576.err-hero::before {
577 content:''; position:absolute; top:0; left:0; right:0; height:2px;
578 background:linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
579 opacity:0.75;
580}
581.err-orb {
582 position:absolute; inset:-30% -15% auto auto; width:460px; height:460px;
583 background:radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
584 filter:blur(80px); opacity:0.75; z-index:0;
585}
586.err-hero-inner { position:relative; z-index:1; }
587.err-eyebrow {
588 display:inline-flex; align-items:center; gap:8px; text-transform:uppercase;
589 font-family:var(--font-mono); font-size:11px; letter-spacing:0.18em;
590 color:var(--text-muted); font-weight:600; margin-bottom:18px;
591}
592.err-eyebrow-dot {
593 width:8px; height:8px; border-radius:9999px;
594 background:linear-gradient(135deg,#8c6dff,#36c5d6);
595 box-shadow:0 0 0 3px rgba(140,109,255,0.18);
596}
597.err-code {
598 font-family:var(--font-display); font-size:clamp(56px,8vw,96px); line-height:0.95;
599 font-weight:800; letter-spacing:-0.04em; margin:0 0 12px;
600 background:linear-gradient(135deg,#a855f7 0%,#06b6d4 100%);
601 -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; color:transparent;
602}
603.err-title { font-family:var(--font-display); font-size:clamp(22px,3vw,30px); font-weight:700; letter-spacing:-0.022em; line-height:1.2; margin:0 0 12px; color:var(--text-strong); }
604.err-body { font-size:16px; line-height:1.6; color:var(--text-muted); max-width:560px; margin:0 0 16px; }
605.err-actions { display:flex; gap:10px; flex-wrap:wrap; margin:22px 0 28px; }
606.err-btn { display:inline-flex; align-items:center; justify-content:center; padding:11px 20px; border-radius:10px; font-size:14px; font-weight:600; text-decoration:none; border:1px solid transparent; line-height:1; transition:transform 120ms ease, box-shadow 120ms ease, background 120ms ease; }
607.err-btn-primary { background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%); color:#fff; box-shadow:0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16); }
608.err-btn-primary:hover { transform:translateY(-1px); }
609.err-btn-ghost { background:transparent; color:var(--text); border-color:var(--border-strong); }
610.err-btn-ghost:hover { background:rgba(140,109,255,0.06); border-color:rgba(140,109,255,0.45); }
611.err-suggestions { list-style:none; padding:0; margin:0 0 24px; border-top:1px solid var(--border); }
612.err-suggestions a { display:flex; align-items:baseline; gap:10px; padding:12px 4px; border-bottom:1px solid var(--border); color:var(--text); text-decoration:none; transition:padding-left 120ms ease; }
613.err-suggestions a:hover { padding-left:8px; color:var(--text-strong); }
614.err-suggestion-label { font-size:14.5px; font-weight:600; color:var(--text-strong); }
615.err-suggestion-hint { flex:1; font-size:12.5px; color:var(--text-muted); }
616.err-suggestion-arrow { font-size:14px; color:var(--accent); }
617.err-meta-block { display:flex; flex-direction:column; gap:8px; margin-top:4px; }
618.err-meta { display:inline-flex; align-items:center; gap:10px; font-size:12.5px; color:var(--text-muted); flex-wrap:wrap; }
619.err-meta-label { text-transform:uppercase; letter-spacing:0.14em; font-size:10.5px; font-weight:700; color:var(--text-muted); }
620.err-meta-value, .meta-mono { font-family:var(--font-mono); background:rgba(255,255,255,0.04); border:1px solid var(--border); border-radius:6px; padding:3px 8px; font-size:12px; color:var(--text); }
621@media (max-width:540px) {
622 .err-page { margin:24px auto 56px; }
623 .err-hero { padding:24px 20px 28px; border-radius:16px; }
624}
c63b860Claude625</style>
626</head>
627<body>
48c9efcClaude628<main class="err-page" role="main" aria-labelledby="error-page-title" data-error-code="${escapeHtml(code)}">
629 <div class="err-hero">
630 <div class="err-orb" aria-hidden="true"></div>
631 <div class="err-hero-inner">
632 <div class="err-eyebrow"><span class="err-eyebrow-dot" aria-hidden="true"></span>${escapeHtml(eyebrow)}</div>
633 <div class="err-code" aria-hidden="true">${escapeHtml(code)}</div>
634 <h1 id="error-page-title" class="err-title">${escapeHtml(title)}</h1>
635 <p class="err-body">${escapeHtml(body)}</p>
636 <div class="err-actions">
637 <a href="/" class="err-btn err-btn-primary" data-error-cta="primary">Go home</a>
638 <a href="/status" class="err-btn err-btn-ghost" data-error-cta="secondary">Status page</a>
639 </div>
640 <ul class="err-suggestions" aria-label="Try one of these">${suggestionsHtml}</ul>
641 ${requestIdHtml}
642 </div>
c63b860Claude643 </div>
644</main>
645</body>
646</html>`;
647}
648
649function escapeHtml(s: string): string {
650 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
651}