WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

feat(web): BaseLayout accepts resolvedTheme prop, adds Accept-CH meta (ATB-53)

Update BaseLayout to take a required resolvedTheme prop that drives dynamic :root CSS token injection, font URL rendering, and optional cssOverrides. Remove hardcoded neobrutal-light preset import and static ROOT_CSS constant. Add Accept-CH meta tag for color scheme client hint. Update all route factories to read theme from context (falling back to FALLBACK_THEME when middleware is absent, e.g. in tests).

+151 -61
+54 -7
apps/web/src/layouts/__tests__/base.test.tsx
··· 2 2 import { Hono } from "hono"; 3 3 import { BaseLayout } from "../base.js"; 4 4 import type { WebSession } from "../../lib/session.js"; 5 + import { FALLBACK_THEME } from "../../lib/theme-resolution.js"; 5 6 6 7 const app = new Hono().get("/", (c) => 7 - c.html(<BaseLayout title="Test Page">Page content</BaseLayout>) 8 + c.html( 9 + <BaseLayout title="Test Page" resolvedTheme={FALLBACK_THEME}> 10 + Page content 11 + </BaseLayout> 12 + ) 8 13 ); 9 14 10 15 describe("BaseLayout", () => { ··· 46 51 47 52 it("falls back to default title when none provided", async () => { 48 53 const defaultApp = new Hono().get("/", (c) => 49 - c.html(<BaseLayout>content</BaseLayout>) 54 + c.html( 55 + <BaseLayout resolvedTheme={FALLBACK_THEME}>content</BaseLayout> 56 + ) 50 57 ); 51 58 const res = await defaultApp.request("/"); 52 59 const html = await res.text(); ··· 66 73 expect(html).toContain('class="site-header__title"'); 67 74 }); 68 75 76 + it("includes Accept-CH meta tag for color scheme hint", async () => { 77 + const res = await app.request("/"); 78 + const html = await res.text(); 79 + expect(html).toContain('http-equiv="Accept-CH"'); 80 + expect(html).toContain('content="Sec-CH-Prefers-Color-Scheme"'); 81 + }); 82 + 83 + it("renders cssOverrides in a style tag when non-null", async () => { 84 + const themeWithOverrides = { 85 + ...FALLBACK_THEME, 86 + cssOverrides: ".card { border: 2px solid black; }", 87 + }; 88 + const overridesApp = new Hono().get("/", (c) => 89 + c.html( 90 + <BaseLayout resolvedTheme={themeWithOverrides}>content</BaseLayout> 91 + ) 92 + ); 93 + const res = await overridesApp.request("/"); 94 + const html = await res.text(); 95 + expect(html).toContain(".card { border: 2px solid black; }"); 96 + }); 97 + 69 98 describe("auth-aware navigation", () => { 70 99 it("shows Log in link when auth is not provided (default unauthenticated)", async () => { 71 100 const unauthApp = new Hono().get("/", (c) => 72 - c.html(<BaseLayout>content</BaseLayout>) 101 + c.html( 102 + <BaseLayout resolvedTheme={FALLBACK_THEME}>content</BaseLayout> 103 + ) 73 104 ); 74 105 const res = await unauthApp.request("/"); 75 106 const html = await res.text(); ··· 80 111 it("shows Log in link when auth is explicitly unauthenticated", async () => { 81 112 const auth: WebSession = { authenticated: false }; 82 113 const unauthApp = new Hono().get("/", (c) => 83 - c.html(<BaseLayout auth={auth}>content</BaseLayout>) 114 + c.html( 115 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 116 + content 117 + </BaseLayout> 118 + ) 84 119 ); 85 120 const res = await unauthApp.request("/"); 86 121 const html = await res.text(); ··· 96 131 handle: "alice.bsky.social", 97 132 }; 98 133 const authApp = new Hono().get("/", (c) => 99 - c.html(<BaseLayout auth={auth}>content</BaseLayout>) 134 + c.html( 135 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 136 + content 137 + </BaseLayout> 138 + ) 100 139 ); 101 140 const res = await authApp.request("/"); 102 141 const html = await res.text(); ··· 112 151 handle: "alice.bsky.social", 113 152 }; 114 153 const authApp = new Hono().get("/", (c) => 115 - c.html(<BaseLayout auth={auth}>content</BaseLayout>) 154 + c.html( 155 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 156 + content 157 + </BaseLayout> 158 + ) 116 159 ); 117 160 const res = await authApp.request("/"); 118 161 const html = await res.text(); ··· 199 242 handle: "alice.bsky.social", 200 243 }; 201 244 const authApp = new Hono().get("/", (c) => 202 - c.html(<BaseLayout auth={auth}>content</BaseLayout>) 245 + c.html( 246 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 247 + content 248 + </BaseLayout> 249 + ) 203 250 ); 204 251 const res = await authApp.request("/"); 205 252 const html = await res.text();
+30 -10
apps/web/src/layouts/base.tsx
··· 1 1 import type { FC, PropsWithChildren } from "hono/jsx"; 2 2 import { tokensToCss } from "../lib/theme.js"; 3 - import neobrutalLight from "../styles/presets/neobrutal-light.json"; 3 + import type { ResolvedTheme } from "../lib/theme-resolution.js"; 4 4 import type { WebSession } from "../lib/session.js"; 5 - 6 - const ROOT_CSS = `:root { ${tokensToCss(neobrutalLight as Record<string, string>)} }`; 7 5 8 6 const NavContent: FC<{ auth?: WebSession }> = ({ auth }) => ( 9 7 <> ··· 25 23 ); 26 24 27 25 export const BaseLayout: FC< 28 - PropsWithChildren<{ title?: string; auth?: WebSession }> 26 + PropsWithChildren<{ 27 + title?: string; 28 + auth?: WebSession; 29 + resolvedTheme: ResolvedTheme; 30 + }> 29 31 > = (props) => { 30 - const { auth } = props; 32 + const { auth, resolvedTheme } = props; 31 33 return ( 32 34 <html lang="en"> 33 35 <head> 34 36 <meta charset="UTF-8" /> 35 37 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 38 + <meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" /> 36 39 <title>{props.title ?? "atBB Forum"}</title> 37 - <style>{ROOT_CSS}</style> 38 - <link rel="preconnect" href="https://fonts.googleapis.com" /> 39 - <link 40 - rel="stylesheet" 41 - href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap" 40 + <style 41 + dangerouslySetInnerHTML={{ 42 + __html: `:root { ${tokensToCss(resolvedTheme.tokens)} }`, 43 + }} 42 44 /> 45 + {resolvedTheme.cssOverrides && ( 46 + <style 47 + dangerouslySetInnerHTML={{ __html: resolvedTheme.cssOverrides }} 48 + /> 49 + )} 50 + {resolvedTheme.fontUrls && resolvedTheme.fontUrls.length > 0 && ( 51 + <> 52 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 53 + <link 54 + rel="preconnect" 55 + href="https://fonts.gstatic.com" 56 + crossorigin="anonymous" 57 + /> 58 + {resolvedTheme.fontUrls.map((url) => ( 59 + <link rel="stylesheet" href={url} /> 60 + ))} 61 + </> 62 + )} 43 63 <link rel="stylesheet" href="/static/css/reset.css" /> 44 64 <link rel="stylesheet" href="/static/css/theme.css" /> 45 65 <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
+33 -22
apps/web/src/routes/admin.tsx
··· 13 13 import { isProgrammingError } from "../lib/errors.js"; 14 14 import { logger } from "../lib/logger.js"; 15 15 import { createAdminThemeRoutes } from "./admin-themes.js"; 16 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 16 17 17 18 // ─── Types ───────────────────────────────────────────────────────────────── 18 19 ··· 206 207 // ─── Routes ──────────────────────────────────────────────────────────────── 207 208 208 209 export function createAdminRoutes(appviewUrl: string) { 209 - const app = new Hono(); 210 + const app = new Hono<WebAppEnv>(); 210 211 211 212 // ─── Structure Page Components ────────────────────────────────────────── 212 213 ··· 364 365 // ── GET /admin ──────────────────────────────────────────────────────────── 365 366 366 367 app.get("/admin", async (c) => { 368 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 367 369 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 368 370 369 371 if (!auth.authenticated) { ··· 372 374 373 375 if (!hasAnyAdminPermission(auth)) { 374 376 return c.html( 375 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 377 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 376 378 <PageHeader title="Access Denied" /> 377 379 <p>You don&apos;t have permission to access the admin panel.</p> 378 380 </BaseLayout>, ··· 386 388 const showThemes = canManageThemes(auth); 387 389 388 390 return c.html( 389 - <BaseLayout title="Admin Panel — atBB Forum" auth={auth}> 391 + <BaseLayout title="Admin Panel — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 390 392 <PageHeader title="Admin Panel" /> 391 393 <div class="admin-nav-grid"> 392 394 {showMembers && ( ··· 435 437 // ── GET /admin/members ──────────────────────────────────────────────────── 436 438 437 439 app.get("/admin/members", async (c) => { 440 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 438 441 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 439 442 440 443 if (!auth.authenticated) { ··· 443 446 444 447 if (!canManageMembers(auth)) { 445 448 return c.html( 446 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 449 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 447 450 <PageHeader title="Members" /> 448 451 <p>You don&apos;t have permission to manage members.</p> 449 452 </BaseLayout>, ··· 471 474 error: error instanceof Error ? error.message : String(error), 472 475 }); 473 476 return c.html( 474 - <BaseLayout title="Members — atBB Forum" auth={auth}> 477 + <BaseLayout title="Members — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 475 478 <PageHeader title="Members" /> 476 479 <ErrorDisplay 477 480 message="Unable to load members" ··· 491 494 status: membersRes.status, 492 495 }); 493 496 return c.html( 494 - <BaseLayout title="Members — atBB Forum" auth={auth}> 497 + <BaseLayout title="Members — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 495 498 <PageHeader title="Members" /> 496 499 <ErrorDisplay 497 500 message="Something went wrong" ··· 529 532 const title = `Members (${members.length}${isTruncated ? "+" : ""})`; 530 533 531 534 return c.html( 532 - <BaseLayout title="Members — atBB Forum" auth={auth}> 535 + <BaseLayout title="Members — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 533 536 <PageHeader title={title} /> 534 537 {members.length === 0 ? ( 535 538 <EmptyState message="No members yet" /> ··· 744 747 // ── GET /admin/structure ───────────────────────────────────────────────── 745 748 746 749 app.get("/admin/structure", async (c) => { 750 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 747 751 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 748 752 749 753 if (!auth.authenticated) { ··· 752 756 753 757 if (!canManageCategories(auth)) { 754 758 return c.html( 755 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 759 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 756 760 <PageHeader title="Forum Structure" /> 757 761 <p>You don&apos;t have permission to manage forum structure.</p> 758 762 </BaseLayout>, ··· 775 779 error: error instanceof Error ? error.message : String(error), 776 780 }); 777 781 return c.html( 778 - <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 782 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 779 783 <PageHeader title="Forum Structure" /> 780 784 <ErrorDisplay 781 785 message="Unable to load forum structure" ··· 795 799 status: categoriesRes.status, 796 800 }); 797 801 return c.html( 798 - <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 802 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 799 803 <PageHeader title="Forum Structure" /> 800 804 <ErrorDisplay 801 805 message="Something went wrong" ··· 845 849 })); 846 850 847 851 return c.html( 848 - <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 852 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 849 853 <PageHeader title="Forum Structure" /> 850 854 {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 851 855 <div class="structure-page"> ··· 882 886 // ── POST /admin/structure/categories ───────────────────────────────────── 883 887 884 888 app.post("/admin/structure/categories", async (c) => { 889 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 885 890 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 886 891 if (!auth.authenticated) return c.redirect("/login"); 887 892 if (!canManageCategories(auth)) { 888 893 return c.html( 889 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 894 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 890 895 <PageHeader title="Forum Structure" /> 891 896 <p>You don&apos;t have permission to manage forum structure.</p> 892 897 </BaseLayout>, ··· 961 966 // ── POST /admin/structure/categories/:id/edit ───────────────────────────── 962 967 963 968 app.post("/admin/structure/categories/:id/edit", async (c) => { 969 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 964 970 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 965 971 if (!auth.authenticated) return c.redirect("/login"); 966 972 if (!canManageCategories(auth)) { 967 973 return c.html( 968 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 974 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 969 975 <PageHeader title="Forum Structure" /> 970 976 <p>You don&apos;t have permission to manage forum structure.</p> 971 977 </BaseLayout>, ··· 1043 1049 // ── POST /admin/structure/categories/:id/delete ─────────────────────────── 1044 1050 1045 1051 app.post("/admin/structure/categories/:id/delete", async (c) => { 1052 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 1046 1053 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1047 1054 if (!auth.authenticated) return c.redirect("/login"); 1048 1055 if (!canManageCategories(auth)) { 1049 1056 return c.html( 1050 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1057 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1051 1058 <PageHeader title="Forum Structure" /> 1052 1059 <p>You don&apos;t have permission to manage forum structure.</p> 1053 1060 </BaseLayout>, ··· 1096 1103 // ── POST /admin/structure/boards ────────────────────────────────────────── 1097 1104 1098 1105 app.post("/admin/structure/boards", async (c) => { 1106 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 1099 1107 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1100 1108 if (!auth.authenticated) return c.redirect("/login"); 1101 1109 if (!canManageCategories(auth)) { 1102 1110 return c.html( 1103 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1111 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1104 1112 <PageHeader title="Forum Structure" /> 1105 1113 <p>You don&apos;t have permission to manage forum structure.</p> 1106 1114 </BaseLayout>, ··· 1183 1191 // ── POST /admin/structure/boards/:id/edit ───────────────────────────────── 1184 1192 1185 1193 app.post("/admin/structure/boards/:id/edit", async (c) => { 1194 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 1186 1195 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1187 1196 if (!auth.authenticated) return c.redirect("/login"); 1188 1197 if (!canManageCategories(auth)) { 1189 1198 return c.html( 1190 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1199 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1191 1200 <PageHeader title="Forum Structure" /> 1192 1201 <p>You don&apos;t have permission to manage forum structure.</p> 1193 1202 </BaseLayout>, ··· 1265 1274 // ── POST /admin/structure/boards/:id/delete ─────────────────────────────── 1266 1275 1267 1276 app.post("/admin/structure/boards/:id/delete", async (c) => { 1277 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 1268 1278 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1269 1279 if (!auth.authenticated) return c.redirect("/login"); 1270 1280 if (!canManageCategories(auth)) { 1271 1281 return c.html( 1272 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1282 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1273 1283 <PageHeader title="Forum Structure" /> 1274 1284 <p>You don&apos;t have permission to manage forum structure.</p> 1275 1285 </BaseLayout>, ··· 1318 1328 // ── GET /admin/modlog ───────────────────────────────────────────────────── 1319 1329 1320 1330 app.get("/admin/modlog", async (c) => { 1331 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 1321 1332 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1322 1333 1323 1334 if (!auth.authenticated) { ··· 1326 1337 1327 1338 if (!canViewModLog(auth)) { 1328 1339 return c.html( 1329 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1340 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1330 1341 <PageHeader title="Mod Action Log" /> 1331 1342 <p>You don&apos;t have permission to view the mod action log.</p> 1332 1343 </BaseLayout>, ··· 1355 1366 error: error instanceof Error ? error.message : String(error), 1356 1367 }); 1357 1368 return c.html( 1358 - <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}> 1369 + <BaseLayout title="Mod Action Log — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1359 1370 <PageHeader title="Mod Action Log" /> 1360 1371 <ErrorDisplay 1361 1372 message="Unable to load mod action log" ··· 1375 1386 status: modlogRes.status, 1376 1387 }); 1377 1388 return c.html( 1378 - <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}> 1389 + <BaseLayout title="Mod Action Log — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1379 1390 <PageHeader title="Mod Action Log" /> 1380 1391 <ErrorDisplay 1381 1392 message="Something went wrong" ··· 1400 1411 operation: "GET /admin/modlog", 1401 1412 }); 1402 1413 return c.html( 1403 - <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}> 1414 + <BaseLayout title="Mod Action Log — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1404 1415 <PageHeader title="Mod Action Log" /> 1405 1416 <ErrorDisplay 1406 1417 message="Something went wrong" ··· 1418 1429 const hasNext = offset + limit < total; 1419 1430 1420 1431 return c.html( 1421 - <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}> 1432 + <BaseLayout title="Mod Action Log — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1422 1433 <PageHeader title="Mod Action Log" /> 1423 1434 {actions.length === 0 ? ( 1424 1435 <EmptyState message="No moderation actions yet" />
+7 -5
apps/web/src/routes/boards.tsx
··· 6 6 import { isProgrammingError, isNetworkError, isNotFoundError } from "../lib/errors.js"; 7 7 import { timeAgo } from "../lib/time.js"; 8 8 import { logger } from "../lib/logger.js"; 9 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 9 10 10 11 // API response type shapes 11 12 ··· 135 136 // ─── Route factory ─────────────────────────────────────────────────────────── 136 137 137 138 export function createBoardsRoutes(appviewUrl: string) { 138 - return new Hono().get("/boards/:id", async (c) => { 139 + return new Hono<WebAppEnv>().get("/boards/:id", async (c) => { 140 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 139 141 const idParam = c.req.param("id"); 140 142 141 143 // Validate that the ID is an integer (parseable as BigInt) ··· 145 147 return c.html("", 200); 146 148 } 147 149 return c.html( 148 - <BaseLayout title="Bad Request — atBB Forum"> 150 + <BaseLayout title="Bad Request — atBB Forum" resolvedTheme={resolvedTheme}> 149 151 <ErrorDisplay message="Invalid board ID." /> 150 152 </BaseLayout>, 151 153 400 ··· 206 208 207 209 if (isNotFoundError(error)) { 208 210 return c.html( 209 - <BaseLayout title="Not Found — atBB Forum" auth={auth}> 211 + <BaseLayout title="Not Found — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 210 212 <ErrorDisplay message="This board doesn't exist." /> 211 213 </BaseLayout>, 212 214 404 ··· 224 226 ? "The forum is temporarily unavailable. Please try again later." 225 227 : "Something went wrong loading this board. Please try again later."; 226 228 return c.html( 227 - <BaseLayout title="Error — atBB Forum" auth={auth}> 229 + <BaseLayout title="Error — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 228 230 <ErrorDisplay message={message} /> 229 231 </BaseLayout>, 230 232 status ··· 250 252 const { topics, total, offset } = topicsData; 251 253 252 254 return c.html( 253 - <BaseLayout title={`${board.name} — atBB Forum`} auth={auth}> 255 + <BaseLayout title={`${board.name} — atBB Forum`} auth={auth} resolvedTheme={resolvedTheme}> 254 256 <nav class="breadcrumb" aria-label="Breadcrumb"> 255 257 <ol> 256 258 <li><a href="/">Home</a></li>
+6 -4
apps/web/src/routes/home.tsx
··· 10 10 import { getSession } from "../lib/session.js"; 11 11 import { isProgrammingError, isNetworkError } from "../lib/errors.js"; 12 12 import { logger } from "../lib/logger.js"; 13 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 13 14 14 15 // API response type shapes 15 16 interface ForumResponse { ··· 48 49 49 50 50 51 export function createHomeRoutes(appviewUrl: string) { 51 - return new Hono().get("/", async (c) => { 52 + return new Hono<WebAppEnv>().get("/", async (c) => { 53 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 52 54 const auth = await getSession(appviewUrl, c.req.header("cookie")); 53 55 54 56 // Stage 1: fetch forum metadata and category list in parallel ··· 73 75 ? "The forum is temporarily unavailable. Please try again later." 74 76 : "Something went wrong loading the forum. Please try again later."; 75 77 return c.html( 76 - <BaseLayout title="Error — atBB Forum" auth={auth}> 78 + <BaseLayout title="Error — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 77 79 <ErrorDisplay message={message} /> 78 80 </BaseLayout>, 79 81 status ··· 102 104 ? "The forum is temporarily unavailable. Please try again later." 103 105 : "Something went wrong loading the forum. Please try again later."; 104 106 return c.html( 105 - <BaseLayout title="Error — atBB Forum" auth={auth}> 107 + <BaseLayout title="Error — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 106 108 <ErrorDisplay message={message} /> 107 109 </BaseLayout>, 108 110 status ··· 116 118 })); 117 119 118 120 return c.html( 119 - <BaseLayout title={`${forum.name} — atBB Forum`} auth={auth}> 121 + <BaseLayout title={`${forum.name} — atBB Forum`} auth={auth} resolvedTheme={resolvedTheme}> 120 122 <PageHeader title={forum.name} description={forum.description ?? undefined} /> 121 123 {categorySections.length === 0 ? ( 122 124 <EmptyState message="No categories yet." />
+4 -2
apps/web/src/routes/login.tsx
··· 2 2 import { BaseLayout } from "../layouts/base.js"; 3 3 import { PageHeader } from "../components/index.js"; 4 4 import { getSession } from "../lib/session.js"; 5 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 5 6 6 7 export function createLoginRoutes(appviewUrl: string) { 7 - return new Hono().get("/login", async (c) => { 8 + return new Hono<WebAppEnv>().get("/login", async (c) => { 9 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 8 10 const auth = await getSession(appviewUrl, c.req.header("cookie")); 9 11 10 12 // If already logged in, redirect to homepage ··· 25 27 : undefined; 26 28 27 29 return c.html( 28 - <BaseLayout title="Sign in — atBB Forum" auth={auth}> 30 + <BaseLayout title="Sign in — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 29 31 <PageHeader 30 32 title="Sign in" 31 33 description="Sign in with your Internet Handle."
+6 -4
apps/web/src/routes/new-topic.tsx
··· 8 8 isNotFoundError, 9 9 } from "../lib/errors.js"; 10 10 import { logger } from "../lib/logger.js"; 11 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 11 12 12 13 interface BoardResponse { 13 14 id: string; ··· 41 42 `; 42 43 43 44 export function createNewTopicRoutes(appviewUrl: string) { 44 - return new Hono() 45 + return new Hono<WebAppEnv>() 45 46 .get("/new-topic", async (c) => { 47 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 46 48 const boardIdParam = c.req.query("boardId"); 47 49 48 50 // boardId required and must be numeric ··· 54 56 55 57 if (!auth.authenticated) { 56 58 return c.html( 57 - <BaseLayout title="New Topic — atBB Forum" auth={auth}> 59 + <BaseLayout title="New Topic — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 58 60 <PageHeader title="New Topic" /> 59 61 <p> 60 62 <a href="/login">Log in</a> to create a topic. ··· 72 74 73 75 if (isNotFoundError(error)) { 74 76 return c.html( 75 - <BaseLayout title="Not Found — atBB Forum" auth={auth}> 77 + <BaseLayout title="Not Found — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 76 78 <ErrorDisplay message="This board doesn't exist." /> 77 79 </BaseLayout>, 78 80 404 ··· 88 90 } 89 91 90 92 return c.html( 91 - <BaseLayout title="New Topic — atBB Forum" auth={auth}> 93 + <BaseLayout title="New Topic — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 92 94 <nav class="breadcrumb" aria-label="Breadcrumb"> 93 95 <ol> 94 96 <li><a href="/">Home</a></li>
+4 -2
apps/web/src/routes/not-found.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 3 import { getSession } from "../lib/session.js"; 4 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 4 5 5 6 export function createNotFoundRoute(appviewUrl?: string) { 6 - return new Hono().all("*", async (c) => { 7 + return new Hono<WebAppEnv>().all("*", async (c) => { 8 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 7 9 const auth = appviewUrl 8 10 ? await getSession(appviewUrl, c.req.header("cookie")) 9 11 : { authenticated: false as const }; 10 12 return c.html( 11 - <BaseLayout title="Page Not Found — atBB Forum" auth={auth}> 13 + <BaseLayout title="Page Not Found — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 12 14 <div class="error-page"> 13 15 <div class="error-page__code">404</div> 14 16 <h1 class="error-page__title">Page Not Found</h1>
+7 -5
apps/web/src/routes/topics.tsx
··· 15 15 } from "../lib/errors.js"; 16 16 import { timeAgo } from "../lib/time.js"; 17 17 import { logger } from "../lib/logger.js"; 18 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 18 19 19 20 // ─── API response types ─────────────────────────────────────────────────────── 20 21 ··· 253 254 // ─── Route factory ──────────────────────────────────────────────────────────── 254 255 255 256 export function createTopicsRoutes(appviewUrl: string) { 256 - return new Hono().get("/topics/:id", async (c) => { 257 + return new Hono<WebAppEnv>().get("/topics/:id", async (c) => { 258 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 257 259 const idParam = c.req.param("id"); 258 260 const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10); 259 261 const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw; ··· 264 266 return c.html("", 200); 265 267 } 266 268 return c.html( 267 - <BaseLayout title="Bad Request — atBB Forum"> 269 + <BaseLayout title="Bad Request — atBB Forum" resolvedTheme={resolvedTheme}> 268 270 <ErrorDisplay message="Invalid topic ID." /> 269 271 </BaseLayout>, 270 272 400 ··· 339 341 340 342 if (isNotFoundError(error)) { 341 343 return c.html( 342 - <BaseLayout title="Not Found — atBB Forum" auth={auth}> 344 + <BaseLayout title="Not Found — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 343 345 <ErrorDisplay message="This topic doesn't exist." /> 344 346 </BaseLayout>, 345 347 404 ··· 357 359 ? "The forum is temporarily unavailable. Please try again later." 358 360 : "Something went wrong loading this topic. Please try again later."; 359 361 return c.html( 360 - <BaseLayout title="Error — atBB Forum" auth={auth}> 362 + <BaseLayout title="Error — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 361 363 <ErrorDisplay message={message} /> 362 364 </BaseLayout>, 363 365 status ··· 406 408 const topicTitle = topicData.post.title ?? topicData.post.text.slice(0, 60); 407 409 408 410 return c.html( 409 - <BaseLayout title={`${topicTitle} — atBB Forum`} auth={auth}> 411 + <BaseLayout title={`${topicTitle} — atBB Forum`} auth={auth} resolvedTheme={resolvedTheme}> 410 412 <nav class="breadcrumb" aria-label="Breadcrumb"> 411 413 <ol> 412 414 <li><a href="/">Home</a></li>