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.
| c63b860 | 1 | /** |
| 48c9efc | 2 | * BLOCK O2 — Shared error page surface (404 / 500 / 403 / 429). |
| c63b860 | 3 | * |
| 48c9efc | 4 | * 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`) | |
| c63b860 | 9 | * |
| 48c9efc | 10 | * 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 | |
| c63b860 | 26 | */ |
| 27 | ||
| 28 | import type { FC } from "hono/jsx"; | |
| 29 | import type { User } from "../db/schema"; | |
| 30 | import { Layout } from "./layout"; | |
| 31 | ||
| 48c9efc | 32 | export interface ErrorPageSuggestion { href: string; label: string; hint?: string } |
| c63b860 | 33 | export 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; | |
| 48c9efc | 46 | /** Optional helper paragraph rendered between body + actions. */ |
| 47 | note?: string; | |
| 48 | /** Optional retry-after seconds (used by the 429 surface). */ | |
| 49 | retryAfterSeconds?: number; | |
| c63b860 | 50 | } |
| 51 | ||
| 52 | export const ErrorPage: FC<ErrorPageProps> = ({ | |
| 53 | code, eyebrow, title, body, requestId, user, suggestions, | |
| 48c9efc | 54 | primaryCta, secondaryCta, meta, trace, layoutTitle, note, retryAfterSeconds, |
| c63b860 | 55 | }) => { |
| 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}> | |
| 48c9efc | 60 | <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">→</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 | )} | |
| c63b860 | 127 | </div> |
| 48c9efc | 128 | </div> |
| c63b860 | 129 | </main> |
| 130 | <style dangerouslySetInnerHTML={{ __html: errorPageCss }} /> | |
| 131 | </Layout> | |
| 132 | ); | |
| 133 | }; | |
| 134 | ||
| 48c9efc | 135 | /* ───────────────────────────────────────────────────────────────────────── |
| 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 | * ───────────────────────────────────────────────────────────────────── */ | |
| 142 | export 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 | }; | |
| c63b860 | 173 | |
| 48c9efc | 174 | /* ───────────────────────────────────────────────────────────────────────── |
| 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 | * ───────────────────────────────────────────────────────────────────── */ | |
| c63b860 | 179 | export 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." | |
| 48c9efc | 184 | 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." |
| c63b860 | 185 | user={user} |
| 186 | primaryCta={{ href: "/", label: "Go home" }} | |
| 48c9efc | 187 | 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 | ]} | |
| c63b860 | 192 | requestId={requestId} |
| 193 | trace={trace} | |
| 194 | layoutTitle="Error" | |
| 195 | /> | |
| 196 | ); | |
| 197 | ||
| 48c9efc | 198 | /* ───────────────────────────────────────────────────────────────────────── |
| 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 | * ───────────────────────────────────────────────────────────────────── */ | |
| c63b860 | 203 | export const ForbiddenPage: FC<{ user?: User | null; message?: string }> = ({ user, message }) => { |
| 204 | const suggestions: ErrorPageSuggestion[] = user | |
| 48c9efc | 205 | ? [ |
| 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 | ]; | |
| c63b860 | 213 | 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" }} | |
| 48c9efc | 221 | secondaryCta={{ href: "/help", label: "Get help" }} |
| c63b860 | 222 | suggestions={suggestions} |
| 223 | layoutTitle="Forbidden" | |
| 224 | /> | |
| 225 | ); | |
| 226 | }; | |
| 227 | ||
| 48c9efc | 228 | /* ───────────────────────────────────────────────────────────────────────── |
| 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 | * ───────────────────────────────────────────────────────────────────── */ | |
| 234 | export 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 | * ───────────────────────────────────────────────────────────────────── */ | |
| c63b860 | 266 | export const errorPageCss = ` |
| 48c9efc | 267 | .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 | } | |
| c63b860 | 520 | `; |
| 521 | ||
| 48c9efc | 522 | function 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 | * ───────────────────────────────────────────────────────────────────── */ | |
| c63b860 | 536 | export 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 | |
| 48c9efc | 542 | ? `<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">→</span></a></li> |
| 543 | <li><a href="/help"><span class="err-suggestion-label">Read the access docs</span><span class="err-suggestion-hint">permissions & teams</span><span class="err-suggestion-arrow" aria-hidden="true">→</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">→</span></a></li> | |
| 545 | <li><a href="/help"><span class="err-suggestion-label">Read the access docs</span><span class="err-suggestion-hint">permissions & teams</span><span class="err-suggestion-arrow" aria-hidden="true">→</span></a></li>`; | |
| c63b860 | 546 | const requestIdHtml = requestId |
| 48c9efc | 547 | ? `<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>` |
| c63b860 | 548 | : ""; |
| 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> | |
| 48c9efc | 556 | :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 | } | |
| 563 | html,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; } | |
| 564 | body::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 | } | |
| c63b860 | 625 | </style> |
| 626 | </head> | |
| 627 | <body> | |
| 48c9efc | 628 | <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> | |
| c63b860 | 643 | </div> |
| 644 | </main> | |
| 645 | </body> | |
| 646 | </html>`; | |
| 647 | } | |
| 648 | ||
| 649 | function escapeHtml(s: string): string { | |
| 650 | return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); | |
| 651 | } |