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): ATB-26 — neobrutal design system, components, and route stubs (#39)

* docs: ATB-26 web UI foundation design

Design doc for the neobrutal design system, layout, static file
serving, shared components, and route stubs that form the Phase 4
web UI foundation.

* docs: ATB-26 web UI foundation implementation plan

Step-by-step TDD plan for neobrutal design system, shared components,
static file serving, and route stubs. Notes frontend-design skill
for CSS content.

* feat(web): add tokensToCss utility for CSS custom property injection

* feat(web): add neobrutal light design token preset

* feat(web): update BaseLayout with neobrutal tokens, fonts, and semantic HTML

* feat(web): add static file serving and neobrutal CSS

- Mount serveStatic middleware on /static/* in index.ts
- Add reset.css with minimal normalize (box-sizing, font inheritance, img display:block)
- Add theme.css with full neobrutal component styles (body, headings, links, code, layout, card, btn, page-header, error-display, empty-state, loading-state)
- All theme.css values use var(--token) — no hardcoded colors, sizes, or fonts
- Add 3 tests verifying reset.css/theme.css are served with text/css content-type and 404 for unknown paths

* fix(web): add global error handler and tokenize button press offsets

* feat(web): add Card component

* feat(web): add Button with primary/secondary/danger variants

* feat(web): add PageHeader component

* feat(web): add ErrorDisplay, EmptyState, LoadingState components

* feat(web): add component barrel export

* fix(web): align LoadingState element and add missing test assertions

* feat(web): add route stubs for boards, topics, login, new-topic

- Update home route to use PageHeader + EmptyState with correct title
- Add boards, topics, login, and new-topic route stubs
- All routes use BaseLayout, PageHeader, and EmptyState components
- TDD: 5 new tests in stubs.test.tsx confirming 200 responses and titles

* feat(web): register all route stubs in router

- Import and mount boards, topics, login, and new-topic routes
- All routes now accessible via webRoutes in the main app
- 47 tests pass across 10 test files

* fix(web): address PR review feedback — error handler, token caching, test coverage

- Compute CSS root block once at module load (ROOT_CSS constant in base.tsx)
- Add startup existence check for static file directory with structured error log
- Improve global error handler: DOCTYPE, lang attr, dev-mode diagnostics
- Add tests for global error handler (500 status, HTML content-type, dev/prod modes)
- Tighten theme separator assertion to exact string match
- Add EmptyState negative assertion for missing action element

authored by

Malpercio and committed by
GitHub
69f45f21 591baaca

+2225 -20
+58
apps/web/public/static/css/reset.css
··· 1 + /* reset.css — minimal normalize for atBB */ 2 + 3 + *, 4 + *::before, 5 + *::after { 6 + box-sizing: border-box; 7 + margin: 0; 8 + padding: 0; 9 + } 10 + 11 + html { 12 + -webkit-text-size-adjust: 100%; 13 + tab-size: 4; 14 + } 15 + 16 + body { 17 + min-height: 100vh; 18 + text-rendering: optimizeSpeed; 19 + line-height: 1; 20 + } 21 + 22 + img, 23 + svg, 24 + video, 25 + canvas, 26 + audio, 27 + iframe, 28 + embed, 29 + object { 30 + display: block; 31 + max-width: 100%; 32 + } 33 + 34 + input, 35 + button, 36 + textarea, 37 + select { 38 + font: inherit; 39 + } 40 + 41 + p, 42 + h1, 43 + h2, 44 + h3, 45 + h4, 46 + h5, 47 + h6 { 48 + overflow-wrap: break-word; 49 + } 50 + 51 + ul[role="list"], 52 + ol[role="list"] { 53 + list-style: none; 54 + } 55 + 56 + a { 57 + text-decoration-skip-ink: auto; 58 + }
+218
apps/web/public/static/css/theme.css
··· 1 + /* theme.css — neobrutal component styles for atBB */ 2 + /* All values use var(--token) — no hardcoded colors, sizes, or fonts */ 3 + 4 + /* ─── Base / Typography ─────────────────────────────────────────────────── */ 5 + 6 + body { 7 + font-family: var(--font-body); 8 + font-size: var(--font-size-base); 9 + font-weight: var(--font-weight-normal); 10 + line-height: var(--line-height-body); 11 + color: var(--color-text); 12 + background-color: var(--color-bg); 13 + } 14 + 15 + h1, 16 + h2, 17 + h3, 18 + h4, 19 + h5, 20 + h6 { 21 + font-family: var(--font-heading); 22 + font-weight: var(--font-weight-bold); 23 + line-height: var(--line-height-heading); 24 + } 25 + 26 + a { 27 + color: var(--color-primary); 28 + text-decoration: underline; 29 + } 30 + 31 + a:hover { 32 + color: var(--color-primary-hover); 33 + } 34 + 35 + code { 36 + font-family: var(--font-mono); 37 + font-size: var(--font-size-sm); 38 + background-color: var(--color-code-bg); 39 + color: var(--color-code-text); 40 + padding: var(--space-xs) var(--space-sm); 41 + border-radius: var(--radius); 42 + } 43 + 44 + pre { 45 + font-family: var(--font-mono); 46 + font-size: var(--font-size-sm); 47 + background-color: var(--color-code-bg); 48 + color: var(--color-code-text); 49 + padding: var(--space-md); 50 + border-radius: var(--radius); 51 + overflow-x: auto; 52 + } 53 + 54 + pre code { 55 + padding: 0; 56 + background: none; 57 + } 58 + 59 + /* ─── Layout ────────────────────────────────────────────────────────────── */ 60 + 61 + .site-header { 62 + height: var(--nav-height); 63 + background-color: var(--color-surface); 64 + border-bottom: var(--border-width) solid var(--color-border); 65 + display: flex; 66 + align-items: center; 67 + } 68 + 69 + .site-header__inner { 70 + max-width: var(--content-width); 71 + width: 100%; 72 + margin: 0 auto; 73 + padding: 0 var(--space-md); 74 + display: flex; 75 + align-items: center; 76 + justify-content: space-between; 77 + } 78 + 79 + .site-header__title { 80 + font-family: var(--font-heading); 81 + font-weight: var(--font-weight-bold); 82 + font-size: var(--font-size-lg); 83 + color: var(--color-text); 84 + text-decoration: none; 85 + } 86 + 87 + .site-header__title:hover { 88 + color: var(--color-primary); 89 + } 90 + 91 + .site-header__nav { 92 + display: flex; 93 + align-items: center; 94 + gap: var(--space-md); 95 + } 96 + 97 + .content-container { 98 + max-width: var(--content-width); 99 + margin: 0 auto; 100 + padding: var(--space-xl) var(--space-md); 101 + } 102 + 103 + .site-footer { 104 + padding: var(--space-lg) var(--space-md); 105 + text-align: center; 106 + color: var(--color-text-muted); 107 + font-size: var(--font-size-sm); 108 + border-top: var(--border-width) solid var(--color-border); 109 + } 110 + 111 + /* ─── Card ──────────────────────────────────────────────────────────────── */ 112 + 113 + .card { 114 + background-color: var(--color-surface); 115 + border: var(--border-width) solid var(--color-border); 116 + border-radius: var(--card-radius); 117 + box-shadow: var(--card-shadow); 118 + padding: var(--space-md); 119 + } 120 + 121 + /* ─── Button ────────────────────────────────────────────────────────────── */ 122 + 123 + .btn { 124 + cursor: pointer; 125 + display: inline-flex; 126 + align-items: center; 127 + gap: var(--space-sm); 128 + font-family: var(--font-body); 129 + font-weight: var(--font-weight-bold); 130 + font-size: var(--font-size-base); 131 + line-height: 1; 132 + border: var(--border-width) solid var(--color-border); 133 + border-radius: var(--button-radius); 134 + padding: var(--space-sm) var(--space-md); 135 + box-shadow: var(--button-shadow); 136 + background-color: var(--color-surface); 137 + color: var(--color-text); 138 + text-decoration: none; 139 + transition: transform 0.1s ease, box-shadow 0.1s ease; 140 + user-select: none; 141 + } 142 + 143 + .btn-primary { 144 + background-color: var(--color-primary); 145 + color: var(--color-surface); 146 + } 147 + 148 + .btn-secondary { 149 + background-color: var(--color-secondary); 150 + color: var(--color-surface); 151 + } 152 + 153 + .btn-danger { 154 + background-color: var(--color-danger); 155 + color: var(--color-surface); 156 + } 157 + 158 + .btn:hover { 159 + transform: translate(var(--btn-press-hover), var(--btn-press-hover)); 160 + box-shadow: var(--btn-press-hover) var(--btn-press-hover) 0 var(--color-shadow); 161 + } 162 + 163 + .btn:active { 164 + transform: translate(var(--btn-press-active), var(--btn-press-active)); 165 + box-shadow: none; 166 + } 167 + 168 + /* ─── Page Header ───────────────────────────────────────────────────────── */ 169 + 170 + .page-header { 171 + display: flex; 172 + align-items: center; 173 + justify-content: space-between; 174 + margin-bottom: var(--space-lg); 175 + gap: var(--space-md); 176 + padding-bottom: var(--space-md); 177 + border-bottom: var(--border-width) solid var(--color-border); 178 + } 179 + 180 + .page-header__text h1 { 181 + margin: 0; 182 + font-size: var(--font-size-xl); 183 + } 184 + 185 + .page-header__text p { 186 + margin: var(--space-xs) 0 0; 187 + color: var(--color-text-muted); 188 + font-size: var(--font-size-sm); 189 + } 190 + 191 + /* ─── Error Display ─────────────────────────────────────────────────────── */ 192 + 193 + .error-display { 194 + background-color: var(--color-surface); 195 + border: var(--border-width) solid var(--color-danger); 196 + border-radius: var(--radius); 197 + padding: var(--space-md); 198 + color: var(--color-danger); 199 + } 200 + 201 + /* ─── Empty State ───────────────────────────────────────────────────────── */ 202 + 203 + .empty-state { 204 + text-align: center; 205 + padding: var(--space-xl) var(--space-md); 206 + color: var(--color-text-muted); 207 + } 208 + 209 + /* ─── Loading State (HTMX indicator) ───────────────────────────────────── */ 210 + 211 + .loading-state { 212 + opacity: 0; 213 + transition: opacity 0.2s ease; 214 + } 215 + 216 + .loading-state.htmx-request { 217 + opacity: 1; 218 + }
+72
apps/web/src/__tests__/static.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { serveStatic } from "@hono/node-server/serve-static"; 4 + 5 + describe("global error handler", () => { 6 + const errApp = new Hono(); 7 + errApp.onError((err, c) => { 8 + const detail = 9 + process.env.NODE_ENV !== "production" 10 + ? `<p><code>${err.message}</code></p>` 11 + : ""; 12 + return c.html( 13 + `<!DOCTYPE html><html lang="en"><body><h1>Internal Server Error</h1><p>Something went wrong. Please try again later.</p>${detail}</body></html>`, 14 + 500 15 + ); 16 + }); 17 + errApp.get("/boom", () => { 18 + throw new Error("test error"); 19 + }); 20 + 21 + it("returns 500 with HTML content-type on unhandled error", async () => { 22 + const res = await errApp.request("/boom"); 23 + expect(res.status).toBe(500); 24 + expect(res.headers.get("content-type")).toContain("text/html"); 25 + }); 26 + 27 + it("includes error message in dev mode", async () => { 28 + const original = process.env.NODE_ENV; 29 + process.env.NODE_ENV = "development"; 30 + try { 31 + const res = await errApp.request("/boom"); 32 + const html = await res.text(); 33 + expect(html).toContain("test error"); 34 + } finally { 35 + process.env.NODE_ENV = original; 36 + } 37 + }); 38 + 39 + it("omits error message in production mode", async () => { 40 + const original = process.env.NODE_ENV; 41 + process.env.NODE_ENV = "production"; 42 + try { 43 + const res = await errApp.request("/boom"); 44 + const html = await res.text(); 45 + expect(html).not.toContain("test error"); 46 + } finally { 47 + process.env.NODE_ENV = original; 48 + } 49 + }); 50 + }); 51 + 52 + describe("static file serving", () => { 53 + const app = new Hono(); 54 + app.use("/static/*", serveStatic({ root: "./public" })); 55 + 56 + it("serves reset.css with text/css content-type", async () => { 57 + const res = await app.request("/static/css/reset.css"); 58 + expect(res.status).toBe(200); 59 + expect(res.headers.get("content-type")).toContain("text/css"); 60 + }); 61 + 62 + it("serves theme.css with text/css content-type", async () => { 63 + const res = await app.request("/static/css/theme.css"); 64 + expect(res.status).toBe(200); 65 + expect(res.headers.get("content-type")).toContain("text/css"); 66 + }); 67 + 68 + it("returns 404 for unknown static paths", async () => { 69 + const res = await app.request("/static/css/nonexistent.css"); 70 + expect(res.status).toBe(404); 71 + }); 72 + });
+45
apps/web/src/components/__tests__/button.test.tsx
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { Button } from "../button.js"; 4 + 5 + describe("Button", () => { 6 + it("renders as <button> when no href provided", async () => { 7 + const app = new Hono().get("/", (c) => 8 + c.html(<Button variant="primary">Click</Button>) 9 + ); 10 + const res = await app.request("/"); 11 + const html = await res.text(); 12 + expect(html).toContain("<button"); 13 + expect(html).toContain("btn-primary"); 14 + expect(html).toContain("Click"); 15 + }); 16 + 17 + it("renders as <a> when href provided", async () => { 18 + const app = new Hono().get("/", (c) => 19 + c.html(<Button variant="secondary" href="/boards">Go</Button>) 20 + ); 21 + const res = await app.request("/"); 22 + const html = await res.text(); 23 + expect(html).toContain("<a "); 24 + expect(html).toContain('href="/boards"'); 25 + expect(html).toContain("btn-secondary"); 26 + }); 27 + 28 + it("renders danger variant", async () => { 29 + const app = new Hono().get("/", (c) => 30 + c.html(<Button variant="danger">Delete</Button>) 31 + ); 32 + const res = await app.request("/"); 33 + const html = await res.text(); 34 + expect(html).toContain("btn-danger"); 35 + }); 36 + 37 + it("includes base btn class on all variants", async () => { 38 + const app = new Hono().get("/", (c) => 39 + c.html(<Button variant="primary">x</Button>) 40 + ); 41 + const res = await app.request("/"); 42 + const html = await res.text(); 43 + expect(html).toMatch(/class="btn /); 44 + }); 45 + });
+22
apps/web/src/components/__tests__/card.test.tsx
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { Card } from "../card.js"; 4 + 5 + describe("Card", () => { 6 + it("renders with card class", async () => { 7 + const app = new Hono().get("/", (c) => c.html(<Card>content</Card>)); 8 + const res = await app.request("/"); 9 + const html = await res.text(); 10 + expect(html).toContain('class="card"'); 11 + expect(html).toContain("content"); 12 + }); 13 + 14 + it("appends additional class names", async () => { 15 + const app = new Hono().get("/", (c) => 16 + c.html(<Card class="extra">content</Card>) 17 + ); 18 + const res = await app.request("/"); 19 + const html = await res.text(); 20 + expect(html).toContain('class="card extra"'); 21 + }); 22 + });
+46
apps/web/src/components/__tests__/page-header.test.tsx
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { PageHeader } from "../page-header.js"; 4 + 5 + describe("PageHeader", () => { 6 + it("renders title in h1 with page-header class", async () => { 7 + const app = new Hono().get("/", (c) => 8 + c.html(<PageHeader title="My Page" />) 9 + ); 10 + const res = await app.request("/"); 11 + const html = await res.text(); 12 + expect(html).toContain('class="page-header"'); 13 + expect(html).toContain("<h1"); 14 + expect(html).toContain("My Page"); 15 + }); 16 + 17 + it("renders description when provided", async () => { 18 + const app = new Hono().get("/", (c) => 19 + c.html(<PageHeader title="T" description="A description" />) 20 + ); 21 + const res = await app.request("/"); 22 + const html = await res.text(); 23 + expect(html).toContain("A description"); 24 + }); 25 + 26 + it("omits description element when not provided", async () => { 27 + const app = new Hono().get("/", (c) => 28 + c.html(<PageHeader title="T" />) 29 + ); 30 + const res = await app.request("/"); 31 + const html = await res.text(); 32 + expect(html).not.toMatch(/<p[^>]*>\s*<\/p>/); 33 + }); 34 + 35 + it("renders action slot when provided", async () => { 36 + const app = new Hono().get("/", (c) => 37 + c.html( 38 + <PageHeader title="T" action={<button>New Topic</button>} /> 39 + ) 40 + ); 41 + const res = await app.request("/"); 42 + const html = await res.text(); 43 + expect(html).toContain('class="page-header__action"'); 44 + expect(html).toContain("New Topic"); 45 + }); 46 + });
+93
apps/web/src/components/__tests__/utility-components.test.tsx
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { ErrorDisplay } from "../error-display.js"; 4 + import { EmptyState } from "../empty-state.js"; 5 + import { LoadingState } from "../loading-state.js"; 6 + 7 + describe("ErrorDisplay", () => { 8 + it("renders message with error-display class", async () => { 9 + const app = new Hono().get("/", (c) => 10 + c.html(<ErrorDisplay message="Something went wrong" />) 11 + ); 12 + const res = await app.request("/"); 13 + const html = await res.text(); 14 + expect(html).toContain('class="error-display"'); 15 + expect(html).toContain("Something went wrong"); 16 + }); 17 + 18 + it("renders detail when provided", async () => { 19 + const app = new Hono().get("/", (c) => 20 + c.html(<ErrorDisplay message="Error" detail="Extra context" />) 21 + ); 22 + const res = await app.request("/"); 23 + const html = await res.text(); 24 + expect(html).toContain("Extra context"); 25 + }); 26 + 27 + it("omits detail element when not provided", async () => { 28 + const app = new Hono().get("/", (c) => 29 + c.html(<ErrorDisplay message="Error" />) 30 + ); 31 + const res = await app.request("/"); 32 + const html = await res.text(); 33 + expect(html).not.toMatch(/<p[^>]*class="error-display__detail"[^>]*>/); 34 + }); 35 + }); 36 + 37 + describe("EmptyState", () => { 38 + it("renders message with empty-state class", async () => { 39 + const app = new Hono().get("/", (c) => 40 + c.html(<EmptyState message="No topics yet" />) 41 + ); 42 + const res = await app.request("/"); 43 + const html = await res.text(); 44 + expect(html).toContain('class="empty-state"'); 45 + expect(html).toContain("No topics yet"); 46 + }); 47 + 48 + it("renders action when provided", async () => { 49 + const app = new Hono().get("/", (c) => 50 + c.html( 51 + <EmptyState message="Empty" action={<a href="/new">Create one</a>} /> 52 + ) 53 + ); 54 + const res = await app.request("/"); 55 + const html = await res.text(); 56 + expect(html).toContain('class="empty-state__action"'); 57 + expect(html).toContain("Create one"); 58 + }); 59 + 60 + it("omits action element when not provided", async () => { 61 + const app = new Hono().get("/", (c) => 62 + c.html(<EmptyState message="Empty" />) 63 + ); 64 + const res = await app.request("/"); 65 + const html = await res.text(); 66 + expect(html).not.toContain('class="empty-state__action"'); 67 + }); 68 + }); 69 + 70 + describe("LoadingState", () => { 71 + it("renders with htmx-indicator class", async () => { 72 + const app = new Hono().get("/", (c) => c.html(<LoadingState />)); 73 + const res = await app.request("/"); 74 + const html = await res.text(); 75 + expect(html).toContain("htmx-indicator"); 76 + }); 77 + 78 + it("renders default loading message", async () => { 79 + const app = new Hono().get("/", (c) => c.html(<LoadingState />)); 80 + const res = await app.request("/"); 81 + const html = await res.text(); 82 + expect(html).toContain("Loading"); 83 + }); 84 + 85 + it("renders custom message when provided", async () => { 86 + const app = new Hono().get("/", (c) => 87 + c.html(<LoadingState message="Fetching posts…" />) 88 + ); 89 + const res = await app.request("/"); 90 + const html = await res.text(); 91 + expect(html).toContain("Fetching posts"); 92 + }); 93 + });
+22
apps/web/src/components/button.tsx
··· 1 + import type { FC, PropsWithChildren } from "hono/jsx"; 2 + 3 + type ButtonVariant = "primary" | "secondary" | "danger"; 4 + 5 + interface ButtonProps { 6 + variant: ButtonVariant; 7 + href?: string; 8 + type?: "button" | "submit" | "reset"; 9 + } 10 + 11 + export const Button: FC<PropsWithChildren<ButtonProps>> = ({ 12 + variant, 13 + href, 14 + type = "button", 15 + children, 16 + }) => { 17 + const classes = `btn btn-${variant}`; 18 + if (href) { 19 + return <a href={href} class={classes}>{children}</a>; 20 + } 21 + return <button type={type} class={classes}>{children}</button>; 22 + };
+10
apps/web/src/components/card.tsx
··· 1 + import type { FC, PropsWithChildren } from "hono/jsx"; 2 + 3 + interface CardProps { 4 + class?: string; 5 + } 6 + 7 + export const Card: FC<PropsWithChildren<CardProps>> = ({ children, class: className }) => { 8 + const classes = ["card", className].filter(Boolean).join(" "); 9 + return <div class={classes}>{children}</div>; 10 + };
+13
apps/web/src/components/empty-state.tsx
··· 1 + import type { FC, Child } from "hono/jsx"; 2 + 3 + interface EmptyStateProps { 4 + message: string; 5 + action?: Child; 6 + } 7 + 8 + export const EmptyState: FC<EmptyStateProps> = ({ message, action }) => ( 9 + <div class="empty-state"> 10 + <p>{message}</p> 11 + {action && <div class="empty-state__action">{action}</div>} 12 + </div> 13 + );
+13
apps/web/src/components/error-display.tsx
··· 1 + import type { FC } from "hono/jsx"; 2 + 3 + interface ErrorDisplayProps { 4 + message: string; 5 + detail?: string; 6 + } 7 + 8 + export const ErrorDisplay: FC<ErrorDisplayProps> = ({ message, detail }) => ( 9 + <div class="error-display"> 10 + <p class="error-display__message">{message}</p> 11 + {detail && <p class="error-display__detail">{detail}</p>} 12 + </div> 13 + );
+6
apps/web/src/components/index.ts
··· 1 + export { Card } from "./card.js"; 2 + export { Button } from "./button.js"; 3 + export { PageHeader } from "./page-header.js"; 4 + export { ErrorDisplay } from "./error-display.js"; 5 + export { EmptyState } from "./empty-state.js"; 6 + export { LoadingState } from "./loading-state.js";
+11
apps/web/src/components/loading-state.tsx
··· 1 + import type { FC } from "hono/jsx"; 2 + 3 + interface LoadingStateProps { 4 + message?: string; 5 + } 6 + 7 + export const LoadingState: FC<LoadingStateProps> = ({ message = "Loading\u2026" }) => ( 8 + <div class="loading-state htmx-indicator"> 9 + <p>{message}</p> 10 + </div> 11 + );
+17
apps/web/src/components/page-header.tsx
··· 1 + import type { FC, Child } from "hono/jsx"; 2 + 3 + interface PageHeaderProps { 4 + title: string; 5 + description?: string; 6 + action?: Child; 7 + } 8 + 9 + export const PageHeader: FC<PageHeaderProps> = ({ title, description, action }) => ( 10 + <div class="page-header"> 11 + <div class="page-header__text"> 12 + <h1>{title}</h1> 13 + {description && <p>{description}</p>} 14 + </div> 15 + {action && <div class="page-header__action">{action}</div>} 16 + </div> 17 + );
+29
apps/web/src/index.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { serve } from "@hono/node-server"; 3 + import { serveStatic } from "@hono/node-server/serve-static"; 3 4 import { logger } from "hono/logger"; 5 + import { existsSync } from "node:fs"; 6 + import { resolve } from "node:path"; 4 7 import { webRoutes } from "./routes/index.js"; 5 8 import { loadConfig } from "./lib/config.js"; 6 9 ··· 8 11 const app = new Hono(); 9 12 10 13 app.use("*", logger()); 14 + 15 + const staticRoot = "./public"; 16 + if (!existsSync(resolve(staticRoot))) { 17 + console.error("CRITICAL: Static file directory not found", { 18 + resolvedPath: resolve(staticRoot), 19 + cwd: process.cwd(), 20 + }); 21 + } 22 + app.use("/static/*", serveStatic({ root: staticRoot })); 11 23 app.route("/", webRoutes); 24 + 25 + app.onError((err, c) => { 26 + console.error("Unhandled error in web route", { 27 + path: c.req.path, 28 + method: c.req.method, 29 + error: err.message, 30 + stack: err.stack, 31 + }); 32 + const detail = 33 + process.env.NODE_ENV !== "production" 34 + ? `<p><code>${err.message}</code></p>` 35 + : ""; 36 + return c.html( 37 + `<!DOCTYPE html><html lang="en"><body><h1>Internal Server Error</h1><p>Something went wrong. Please try again later.</p>${detail}</body></html>`, 38 + 500 39 + ); 40 + }); 12 41 13 42 serve( 14 43 {
+67
apps/web/src/layouts/__tests__/base.test.tsx
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { BaseLayout } from "../base.js"; 4 + 5 + const app = new Hono().get("/", (c) => 6 + c.html(<BaseLayout title="Test Page">Page content</BaseLayout>) 7 + ); 8 + 9 + describe("BaseLayout", () => { 10 + it("injects neobrutal tokens as :root CSS custom properties", async () => { 11 + const res = await app.request("/"); 12 + const html = await res.text(); 13 + expect(html).toContain(":root {"); 14 + expect(html).toContain("--color-bg:"); 15 + expect(html).toContain("--color-primary:"); 16 + }); 17 + 18 + it("loads reset.css and theme.css stylesheets", async () => { 19 + const res = await app.request("/"); 20 + const html = await res.text(); 21 + expect(html).toContain('href="/static/css/reset.css"'); 22 + expect(html).toContain('href="/static/css/theme.css"'); 23 + }); 24 + 25 + it("loads Space Grotesk from Google Fonts", async () => { 26 + const res = await app.request("/"); 27 + const html = await res.text(); 28 + expect(html).toContain("fonts.googleapis.com"); 29 + expect(html).toContain("Space+Grotesk"); 30 + }); 31 + 32 + it("renders semantic site-header, content-container, and site-footer", async () => { 33 + const res = await app.request("/"); 34 + const html = await res.text(); 35 + expect(html).toContain('class="site-header"'); 36 + expect(html).toContain('class="content-container"'); 37 + expect(html).toContain('class="site-footer"'); 38 + }); 39 + 40 + it("renders provided page title", async () => { 41 + const res = await app.request("/"); 42 + const html = await res.text(); 43 + expect(html).toContain("<title>Test Page</title>"); 44 + }); 45 + 46 + it("falls back to default title when none provided", async () => { 47 + const defaultApp = new Hono().get("/", (c) => 48 + c.html(<BaseLayout>content</BaseLayout>) 49 + ); 50 + const res = await defaultApp.request("/"); 51 + const html = await res.text(); 52 + expect(html).toContain("<title>atBB Forum</title>"); 53 + }); 54 + 55 + it("renders children inside content-container", async () => { 56 + const res = await app.request("/"); 57 + const html = await res.text(); 58 + expect(html).toContain("Page content"); 59 + }); 60 + 61 + it("renders header title link pointing to /", async () => { 62 + const res = await app.request("/"); 63 + const html = await res.text(); 64 + expect(html).toContain('href="/"'); 65 + expect(html).toContain('class="site-header__title"'); 66 + }); 67 + });
+24 -8
apps/web/src/layouts/base.tsx
··· 1 1 import type { FC, PropsWithChildren } from "hono/jsx"; 2 + import { tokensToCss } from "../lib/theme.js"; 3 + import { neobrutalLight } from "../styles/presets/neobrutal-light.js"; 4 + 5 + const ROOT_CSS = `:root { ${tokensToCss(neobrutalLight)} }`; 2 6 3 7 export const BaseLayout: FC<PropsWithChildren<{ title?: string }>> = (props) => { 4 8 return ( ··· 7 11 <meta charset="UTF-8" /> 8 12 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 9 13 <title>{props.title ?? "atBB Forum"}</title> 10 - <script src="https://unpkg.com/htmx.org@2.0.4" /> 14 + <style>{ROOT_CSS}</style> 15 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 16 + <link 17 + rel="stylesheet" 18 + href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap" 19 + /> 20 + <link rel="stylesheet" href="/static/css/reset.css" /> 21 + <link rel="stylesheet" href="/static/css/theme.css" /> 22 + <script src="https://unpkg.com/htmx.org@2.0.4" defer /> 11 23 </head> 12 24 <body> 13 - <header> 14 - <h1> 15 - <a href="/">atBB Forum</a> 16 - </h1> 17 - <nav>{/* login/logout will go here in Phase 2 */}</nav> 25 + <header class="site-header"> 26 + <div class="site-header__inner"> 27 + <a href="/" class="site-header__title"> 28 + atBB Forum 29 + </a> 30 + <nav class="site-header__nav"> 31 + {/* login/logout — auth ticket */} 32 + </nav> 33 + </div> 18 34 </header> 19 - <main>{props.children}</main> 20 - <footer> 35 + <main class="content-container">{props.children}</main> 36 + <footer class="site-footer"> 21 37 <p>Powered by atBB on the ATmosphere</p> 22 38 </footer> 23 39 </body>
+20
apps/web/src/lib/__tests__/theme.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { tokensToCss } from "../theme.js"; 3 + 4 + describe("tokensToCss", () => { 5 + it("converts token map to CSS custom property declarations", () => { 6 + const tokens = { "color-bg": "#f5f0e8", "font-size-base": "16px" }; 7 + const result = tokensToCss(tokens); 8 + expect(result).toContain("--color-bg: #f5f0e8"); 9 + expect(result).toContain("--font-size-base: 16px"); 10 + }); 11 + 12 + it("returns empty string for empty token map", () => { 13 + expect(tokensToCss({})).toBe(""); 14 + }); 15 + 16 + it("joins declarations with semicolons", () => { 17 + const result = tokensToCss({ a: "1", b: "2" }); 18 + expect(result).toBe("--a: 1; --b: 2"); 19 + }); 20 + });
+5
apps/web/src/lib/theme.ts
··· 1 + export function tokensToCss(tokens: Record<string, string>): string { 2 + return Object.entries(tokens) 3 + .map(([key, value]) => `--${key}: ${value}`) 4 + .join("; "); 5 + }
+43
apps/web/src/routes/__tests__/stubs.test.tsx
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { homeRoutes } from "../home.js"; 3 + import { boardsRoutes } from "../boards.js"; 4 + import { topicsRoutes } from "../topics.js"; 5 + import { loginRoutes } from "../login.js"; 6 + import { newTopicRoutes } from "../new-topic.js"; 7 + 8 + describe("Route stubs", () => { 9 + it("GET / returns 200 with home title", async () => { 10 + const res = await homeRoutes.request("/"); 11 + expect(res.status).toBe(200); 12 + const html = await res.text(); 13 + expect(html).toContain("Home — atBB Forum"); 14 + }); 15 + 16 + it("GET /boards/:id returns 200 with board title", async () => { 17 + const res = await boardsRoutes.request("/boards/123"); 18 + expect(res.status).toBe(200); 19 + const html = await res.text(); 20 + expect(html).toContain("Board — atBB Forum"); 21 + }); 22 + 23 + it("GET /topics/:id returns 200 with topic title", async () => { 24 + const res = await topicsRoutes.request("/topics/123"); 25 + expect(res.status).toBe(200); 26 + const html = await res.text(); 27 + expect(html).toContain("Topic — atBB Forum"); 28 + }); 29 + 30 + it("GET /login returns 200 with login title", async () => { 31 + const res = await loginRoutes.request("/login"); 32 + expect(res.status).toBe(200); 33 + const html = await res.text(); 34 + expect(html).toContain("Login — atBB Forum"); 35 + }); 36 + 37 + it("GET /new-topic returns 200 with new topic title", async () => { 38 + const res = await newTopicRoutes.request("/new-topic"); 39 + expect(res.status).toBe(200); 40 + const html = await res.text(); 41 + expect(html).toContain("New Topic — atBB Forum"); 42 + }); 43 + });
+12
apps/web/src/routes/boards.tsx
··· 1 + import { Hono } from "hono"; 2 + import { BaseLayout } from "../layouts/base.js"; 3 + import { PageHeader, EmptyState } from "../components/index.js"; 4 + 5 + export const boardsRoutes = new Hono().get("/boards/:id", (c) => 6 + c.html( 7 + <BaseLayout title="Board — atBB Forum"> 8 + <PageHeader title="Board" description="Topics will appear here." /> 9 + <EmptyState message="No topics yet." /> 10 + </BaseLayout> 11 + ) 12 + );
+11 -11
apps/web/src/routes/home.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 + import { PageHeader, EmptyState } from "../components/index.js"; 3 4 4 - export const homeRoutes = new Hono().get("/", (c) => { 5 - return c.html( 6 - <BaseLayout title="Home - atBB Forum"> 7 - <h2>Welcome to atBB</h2> 8 - <p>A BB-style forum on the ATmosphere.</p> 9 - <section> 10 - <h3>Categories</h3> 11 - <div id="categories">Loading categories...</div> 12 - </section> 5 + export const homeRoutes = new Hono().get("/", (c) => 6 + c.html( 7 + <BaseLayout title="Home — atBB Forum"> 8 + <PageHeader 9 + title="Welcome to atBB" 10 + description="A BB-style forum on the ATmosphere." 11 + /> 12 + <EmptyState message="No boards yet." /> 13 13 </BaseLayout> 14 - ); 15 - }); 14 + ) 15 + );
+10 -1
apps/web/src/routes/index.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { homeRoutes } from "./home.js"; 3 + import { boardsRoutes } from "./boards.js"; 4 + import { topicsRoutes } from "./topics.js"; 5 + import { loginRoutes } from "./login.js"; 6 + import { newTopicRoutes } from "./new-topic.js"; 3 7 4 - export const webRoutes = new Hono().route("/", homeRoutes); 8 + export const webRoutes = new Hono() 9 + .route("/", homeRoutes) 10 + .route("/", boardsRoutes) 11 + .route("/", topicsRoutes) 12 + .route("/", loginRoutes) 13 + .route("/", newTopicRoutes);
+14
apps/web/src/routes/login.tsx
··· 1 + import { Hono } from "hono"; 2 + import { BaseLayout } from "../layouts/base.js"; 3 + import { PageHeader } from "../components/index.js"; 4 + 5 + export const loginRoutes = new Hono().get("/login", (c) => 6 + c.html( 7 + <BaseLayout title="Login — atBB Forum"> 8 + <PageHeader 9 + title="Sign in" 10 + description="Sign in with your AT Protocol account." 11 + /> 12 + </BaseLayout> 13 + ) 14 + );
+11
apps/web/src/routes/new-topic.tsx
··· 1 + import { Hono } from "hono"; 2 + import { BaseLayout } from "../layouts/base.js"; 3 + import { PageHeader } from "../components/index.js"; 4 + 5 + export const newTopicRoutes = new Hono().get("/new-topic", (c) => 6 + c.html( 7 + <BaseLayout title="New Topic — atBB Forum"> 8 + <PageHeader title="New Topic" description="Compose a new topic." /> 9 + </BaseLayout> 10 + ) 11 + );
+12
apps/web/src/routes/topics.tsx
··· 1 + import { Hono } from "hono"; 2 + import { BaseLayout } from "../layouts/base.js"; 3 + import { PageHeader, EmptyState } from "../components/index.js"; 4 + 5 + export const topicsRoutes = new Hono().get("/topics/:id", (c) => 6 + c.html( 7 + <BaseLayout title="Topic — atBB Forum"> 8 + <PageHeader title="Topic" /> 9 + <EmptyState message="No replies yet." /> 10 + </BaseLayout> 11 + ) 12 + );
+51
apps/web/src/styles/presets/neobrutal-light.ts
··· 1 + // apps/web/src/styles/presets/neobrutal-light.ts 2 + export const neobrutalLight: Record<string, string> = { 3 + // Colors 4 + "color-bg": "#f5f0e8", 5 + "color-surface": "#ffffff", 6 + "color-text": "#1a1a1a", 7 + "color-text-muted": "#555555", 8 + "color-primary": "#ff5c00", 9 + "color-primary-hover": "#e04f00", 10 + "color-secondary": "#3a86ff", 11 + "color-border": "#1a1a1a", 12 + "color-shadow": "#1a1a1a", 13 + "color-success": "#2ec44a", 14 + "color-warning": "#ffbe0b", 15 + "color-danger": "#ff006e", 16 + "color-code-bg": "#1a1a1a", 17 + "color-code-text": "#f5f0e8", 18 + // Typography 19 + "font-body": "'Space Grotesk', system-ui, sans-serif", 20 + "font-heading": "'Space Grotesk', system-ui, sans-serif", 21 + "font-mono": "'JetBrains Mono', ui-monospace, monospace", 22 + "font-size-base": "16px", 23 + "font-size-sm": "14px", 24 + "font-size-lg": "20px", 25 + "font-size-xl": "28px", 26 + "font-size-2xl": "36px", 27 + "font-weight-normal": "400", 28 + "font-weight-bold": "700", 29 + "line-height-body": "1.6", 30 + "line-height-heading": "1.2", 31 + // Spacing & Layout 32 + "space-xs": "4px", 33 + "space-sm": "8px", 34 + "space-md": "16px", 35 + "space-lg": "24px", 36 + "space-xl": "40px", 37 + "radius": "0px", 38 + "border-width": "3px", 39 + "shadow-offset": "4px", 40 + "content-width": "960px", 41 + // Components 42 + "button-radius": "0px", 43 + "button-shadow": "4px 4px 0 var(--color-shadow)", 44 + "card-radius": "0px", 45 + "card-shadow": "6px 6px 0 var(--color-shadow)", 46 + "btn-press-hover": "2px", 47 + "btn-press-active": "4px", 48 + "input-radius": "0px", 49 + "input-border": "3px solid var(--color-border)", 50 + "nav-height": "64px", 51 + };
+1098
docs/plans/2026-02-17-atb-26-web-ui-foundation.md
··· 1 + # ATB-26: Web UI Foundation Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Build the neobrutal CSS design system, shared JSX components, semantic layout, static file serving, and route stubs that all other Phase 4 tickets depend on. 6 + 7 + **Architecture:** Token values live in a typed TypeScript constant (`neobrutal-light.ts`). `tokensToCss()` converts them to a `:root { }` block injected server-side in `BaseLayout` before the `theme.css` link tag. `theme.css` references only `var(--token)` properties. Six shared components cover all Phase 4 UI patterns. Static CSS serves from `apps/web/public/css/` via Hono's `serveStatic`. 8 + 9 + **Tech Stack:** Hono JSX, HTMX (CDN), CSS custom properties, `@hono/node-server/serve-static`, Vitest, Space Grotesk (Google Fonts). Use `frontend-design:frontend-design` skill for CSS content (reset.css, theme.css). 10 + 11 + --- 12 + 13 + ### Task 1: `tokensToCss` utility 14 + 15 + **Files:** 16 + - Create: `apps/web/src/lib/__tests__/theme.test.ts` 17 + - Create: `apps/web/src/lib/theme.ts` 18 + 19 + **Step 1: Write the failing test** 20 + 21 + ```typescript 22 + // apps/web/src/lib/__tests__/theme.test.ts 23 + import { describe, it, expect } from "vitest"; 24 + import { tokensToCss } from "../theme.js"; 25 + 26 + describe("tokensToCss", () => { 27 + it("converts token map to CSS custom property declarations", () => { 28 + const tokens = { "color-bg": "#f5f0e8", "font-size-base": "16px" }; 29 + const result = tokensToCss(tokens); 30 + expect(result).toContain("--color-bg: #f5f0e8"); 31 + expect(result).toContain("--font-size-base: 16px"); 32 + }); 33 + 34 + it("returns empty string for empty token map", () => { 35 + expect(tokensToCss({})).toBe(""); 36 + }); 37 + 38 + it("joins declarations with semicolons", () => { 39 + const result = tokensToCss({ a: "1", b: "2" }); 40 + expect(result).toMatch(/--a: 1;?\s*--b: 2/); 41 + }); 42 + }); 43 + ``` 44 + 45 + **Step 2: Run to verify fail** 46 + 47 + ```bash 48 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/lib/__tests__/theme.test.ts 49 + ``` 50 + Expected: FAIL — "Cannot find module '../theme.js'" 51 + 52 + **Step 3: Implement** 53 + 54 + ```typescript 55 + // apps/web/src/lib/theme.ts 56 + export function tokensToCss(tokens: Record<string, string>): string { 57 + return Object.entries(tokens) 58 + .map(([key, value]) => `--${key}: ${value}`) 59 + .join("; "); 60 + } 61 + ``` 62 + 63 + **Step 4: Run to verify pass** 64 + 65 + ```bash 66 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/lib/__tests__/theme.test.ts 67 + ``` 68 + Expected: PASS (3 tests) 69 + 70 + **Step 5: Commit** 71 + 72 + ```bash 73 + git add apps/web/src/lib/theme.ts apps/web/src/lib/__tests__/theme.test.ts 74 + git commit -m "feat(web): add tokensToCss utility for CSS custom property injection" 75 + ``` 76 + 77 + --- 78 + 79 + ### Task 2: Neobrutal light preset 80 + 81 + **Files:** 82 + - Create: `apps/web/src/styles/presets/neobrutal-light.ts` 83 + 84 + Token values come verbatim from `docs/theming-plan.md` lines 62–121. No separate test — the values are a data constant exercised by the BaseLayout tests in Task 3. 85 + 86 + **Step 1: Create the preset** 87 + 88 + ```typescript 89 + // apps/web/src/styles/presets/neobrutal-light.ts 90 + export const neobrutalLight: Record<string, string> = { 91 + // Colors 92 + "color-bg": "#f5f0e8", 93 + "color-surface": "#ffffff", 94 + "color-text": "#1a1a1a", 95 + "color-text-muted": "#555555", 96 + "color-primary": "#ff5c00", 97 + "color-primary-hover": "#e04f00", 98 + "color-secondary": "#3a86ff", 99 + "color-border": "#1a1a1a", 100 + "color-shadow": "#1a1a1a", 101 + "color-success": "#2ec44a", 102 + "color-warning": "#ffbe0b", 103 + "color-danger": "#ff006e", 104 + "color-code-bg": "#1a1a1a", 105 + "color-code-text": "#f5f0e8", 106 + // Typography 107 + "font-body": "'Space Grotesk', system-ui, sans-serif", 108 + "font-heading": "'Space Grotesk', system-ui, sans-serif", 109 + "font-mono": "'JetBrains Mono', ui-monospace, monospace", 110 + "font-size-base": "16px", 111 + "font-size-sm": "14px", 112 + "font-size-lg": "20px", 113 + "font-size-xl": "28px", 114 + "font-size-2xl": "36px", 115 + "font-weight-normal": "400", 116 + "font-weight-bold": "700", 117 + "line-height-body": "1.6", 118 + "line-height-heading": "1.2", 119 + // Spacing & Layout 120 + "space-xs": "4px", 121 + "space-sm": "8px", 122 + "space-md": "16px", 123 + "space-lg": "24px", 124 + "space-xl": "40px", 125 + "radius": "0px", 126 + "border-width": "3px", 127 + "shadow-offset": "4px", 128 + "content-width": "960px", 129 + // Components 130 + "button-radius": "0px", 131 + "button-shadow": "4px 4px 0 var(--color-shadow)", 132 + "card-radius": "0px", 133 + "card-shadow": "6px 6px 0 var(--color-shadow)", 134 + "input-radius": "0px", 135 + "input-border": "3px solid var(--color-border)", 136 + "nav-height": "64px", 137 + }; 138 + ``` 139 + 140 + **Step 2: Commit** 141 + 142 + ```bash 143 + git add apps/web/src/styles/presets/neobrutal-light.ts 144 + git commit -m "feat(web): add neobrutal light design token preset" 145 + ``` 146 + 147 + --- 148 + 149 + ### Task 3: Update BaseLayout 150 + 151 + **Files:** 152 + - Modify: `apps/web/src/layouts/base.tsx` 153 + - Create: `apps/web/src/layouts/__tests__/base.test.tsx` 154 + 155 + **Step 1: Write the failing tests** 156 + 157 + ```typescript 158 + // apps/web/src/layouts/__tests__/base.test.tsx 159 + import { describe, it, expect } from "vitest"; 160 + import { Hono } from "hono"; 161 + import { BaseLayout } from "../base.js"; 162 + 163 + const app = new Hono().get("/", (c) => 164 + c.html(<BaseLayout title="Test Page">Page content</BaseLayout>) 165 + ); 166 + 167 + describe("BaseLayout", () => { 168 + it("injects neobrutal tokens as :root CSS custom properties", async () => { 169 + const res = await app.request("/"); 170 + const html = await res.text(); 171 + expect(html).toContain(":root {"); 172 + expect(html).toContain("--color-bg:"); 173 + expect(html).toContain("--color-primary:"); 174 + }); 175 + 176 + it("loads reset.css and theme.css stylesheets", async () => { 177 + const res = await app.request("/"); 178 + const html = await res.text(); 179 + expect(html).toContain('href="/static/css/reset.css"'); 180 + expect(html).toContain('href="/static/css/theme.css"'); 181 + }); 182 + 183 + it("loads Space Grotesk from Google Fonts", async () => { 184 + const res = await app.request("/"); 185 + const html = await res.text(); 186 + expect(html).toContain("fonts.googleapis.com"); 187 + expect(html).toContain("Space+Grotesk"); 188 + }); 189 + 190 + it("renders semantic site-header, content-container, and site-footer", async () => { 191 + const res = await app.request("/"); 192 + const html = await res.text(); 193 + expect(html).toContain('class="site-header"'); 194 + expect(html).toContain('class="content-container"'); 195 + expect(html).toContain('class="site-footer"'); 196 + }); 197 + 198 + it("renders provided page title", async () => { 199 + const res = await app.request("/"); 200 + const html = await res.text(); 201 + expect(html).toContain("<title>Test Page</title>"); 202 + }); 203 + 204 + it("falls back to default title when none provided", async () => { 205 + const defaultApp = new Hono().get("/", (c) => 206 + c.html(<BaseLayout>content</BaseLayout>) 207 + ); 208 + const res = await defaultApp.request("/"); 209 + const html = await res.text(); 210 + expect(html).toContain("<title>atBB Forum</title>"); 211 + }); 212 + 213 + it("renders children inside content-container", async () => { 214 + const res = await app.request("/"); 215 + const html = await res.text(); 216 + expect(html).toContain("Page content"); 217 + }); 218 + 219 + it("renders header title link pointing to /", async () => { 220 + const res = await app.request("/"); 221 + const html = await res.text(); 222 + expect(html).toContain('href="/"'); 223 + expect(html).toContain('class="site-header__title"'); 224 + }); 225 + }); 226 + ``` 227 + 228 + **Step 2: Run to verify fail** 229 + 230 + ```bash 231 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/layouts/__tests__/base.test.tsx 232 + ``` 233 + Expected: FAIL — layout missing tokens, CSS links, and semantic classes 234 + 235 + **Step 3: Update BaseLayout** 236 + 237 + ```tsx 238 + // apps/web/src/layouts/base.tsx 239 + import type { FC, PropsWithChildren } from "hono/jsx"; 240 + import { tokensToCss } from "../lib/theme.js"; 241 + import { neobrutalLight } from "../styles/presets/neobrutal-light.js"; 242 + 243 + export const BaseLayout: FC<PropsWithChildren<{ title?: string }>> = (props) => { 244 + return ( 245 + <html lang="en"> 246 + <head> 247 + <meta charset="UTF-8" /> 248 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 249 + <title>{props.title ?? "atBB Forum"}</title> 250 + <style>{`:root { ${tokensToCss(neobrutalLight)} }`}</style> 251 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 252 + <link 253 + rel="stylesheet" 254 + href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap" 255 + /> 256 + <link rel="stylesheet" href="/static/css/reset.css" /> 257 + <link rel="stylesheet" href="/static/css/theme.css" /> 258 + <script src="https://unpkg.com/htmx.org@2.0.4" defer /> 259 + </head> 260 + <body> 261 + <header class="site-header"> 262 + <div class="site-header__inner"> 263 + <a href="/" class="site-header__title"> 264 + atBB Forum 265 + </a> 266 + <nav class="site-header__nav"> 267 + {/* login/logout — auth ticket */} 268 + </nav> 269 + </div> 270 + </header> 271 + <main class="content-container">{props.children}</main> 272 + <footer class="site-footer"> 273 + <p>Powered by atBB on the ATmosphere</p> 274 + </footer> 275 + </body> 276 + </html> 277 + ); 278 + }; 279 + ``` 280 + 281 + **Step 4: Run to verify pass** 282 + 283 + ```bash 284 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/layouts/__tests__/base.test.tsx 285 + ``` 286 + Expected: PASS (8 tests) 287 + 288 + **Step 5: Commit** 289 + 290 + ```bash 291 + git add apps/web/src/layouts/base.tsx apps/web/src/layouts/__tests__/base.test.tsx 292 + git commit -m "feat(web): update BaseLayout with neobrutal tokens, fonts, and semantic HTML" 293 + ``` 294 + 295 + --- 296 + 297 + ### Task 4: Static file serving + CSS files 298 + 299 + **Files:** 300 + - Modify: `apps/web/src/index.ts` 301 + - Create: `apps/web/public/css/reset.css` ← use `frontend-design:frontend-design` skill 302 + - Create: `apps/web/public/css/theme.css` ← use `frontend-design:frontend-design` skill 303 + - Create: `apps/web/src/__tests__/static.test.ts` 304 + 305 + **Step 1: Write the failing test** 306 + 307 + ```typescript 308 + // apps/web/src/__tests__/static.test.ts 309 + import { describe, it, expect } from "vitest"; 310 + import { Hono } from "hono"; 311 + import { serveStatic } from "@hono/node-server/serve-static"; 312 + 313 + describe("static file serving", () => { 314 + const app = new Hono(); 315 + app.use("/static/*", serveStatic({ root: "./public" })); 316 + 317 + it("serves reset.css with text/css content-type", async () => { 318 + const res = await app.request("/static/css/reset.css"); 319 + expect(res.status).toBe(200); 320 + expect(res.headers.get("content-type")).toContain("text/css"); 321 + }); 322 + 323 + it("serves theme.css with text/css content-type", async () => { 324 + const res = await app.request("/static/css/theme.css"); 325 + expect(res.status).toBe(200); 326 + expect(res.headers.get("content-type")).toContain("text/css"); 327 + }); 328 + 329 + it("returns 404 for unknown static paths", async () => { 330 + const res = await app.request("/static/css/nonexistent.css"); 331 + expect(res.status).toBe(404); 332 + }); 333 + }); 334 + ``` 335 + 336 + **Step 2: Run to verify fail** 337 + 338 + ```bash 339 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/__tests__/static.test.ts 340 + ``` 341 + Expected: FAIL — `public/css/reset.css` and `public/css/theme.css` don't exist 342 + 343 + **Step 3: Create the CSS files** 344 + 345 + Invoke `frontend-design:frontend-design` skill. Provide it this context: 346 + 347 + - Token reference: `apps/web/src/styles/presets/neobrutal-light.ts` 348 + - CSS architecture: `docs/theming-plan.md` lines 337–391 349 + - **`reset.css`**: minimal normalize — box-sizing inheritance, zero margin/padding on `*`, font inheritance on form elements, `max-width: 100%` on images. ~20 lines. 350 + - **`theme.css`**: component styles for body/typography, links, headings, code, layout (`.site-header`, `.site-header__inner`, `.site-header__title`, `.site-header__nav`, `.content-container`, `.site-footer`), and components (`.card`, `.btn`, `.btn-primary`, `.btn-secondary`, `.btn-danger`, `.page-header`, `.page-header__text`, `.page-header__action`, `.error-display`, `.empty-state`, `.loading-state`). 351 + - **Hard rule**: `theme.css` must use **only** `var(--token)` references. No hardcoded values anywhere. 352 + - Neobrutal hover pattern for buttons: `transform: translate(2px, 2px); box-shadow: 2px 2px 0 var(--color-shadow)`. 353 + - Header: thick bottom border (`var(--border-width) solid var(--color-border)`), height `var(--nav-height)`, neobrutal signature. 354 + - Files go in `apps/web/public/css/`. 355 + 356 + **Step 4: Add serveStatic to index.ts** 357 + 358 + ```typescript 359 + // apps/web/src/index.ts 360 + import { Hono } from "hono"; 361 + import { serve } from "@hono/node-server"; 362 + import { serveStatic } from "@hono/node-server/serve-static"; 363 + import { logger } from "hono/logger"; 364 + import { webRoutes } from "./routes/index.js"; 365 + import { loadConfig } from "./lib/config.js"; 366 + 367 + const config = loadConfig(); 368 + const app = new Hono(); 369 + 370 + app.use("*", logger()); 371 + app.use("/static/*", serveStatic({ root: "./public" })); 372 + app.route("/", webRoutes); 373 + 374 + serve( 375 + { fetch: app.fetch, port: config.port }, 376 + (info) => { 377 + console.log(`atBB Web UI listening on http://localhost:${info.port}`); 378 + } 379 + ); 380 + ``` 381 + 382 + **Step 5: Run to verify pass** 383 + 384 + ```bash 385 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/__tests__/static.test.ts 386 + ``` 387 + Expected: PASS (3 tests) 388 + 389 + **Step 6: Commit** 390 + 391 + ```bash 392 + git add apps/web/src/index.ts apps/web/src/__tests__/static.test.ts apps/web/public/ 393 + git commit -m "feat(web): add static file serving and neobrutal CSS" 394 + ``` 395 + 396 + --- 397 + 398 + ### Task 5: Card component 399 + 400 + **Files:** 401 + - Create: `apps/web/src/components/__tests__/card.test.tsx` 402 + - Create: `apps/web/src/components/card.tsx` 403 + 404 + **Step 1: Write the failing test** 405 + 406 + ```typescript 407 + // apps/web/src/components/__tests__/card.test.tsx 408 + import { describe, it, expect } from "vitest"; 409 + import { Hono } from "hono"; 410 + import { Card } from "../card.js"; 411 + 412 + describe("Card", () => { 413 + it("renders with card class", async () => { 414 + const app = new Hono().get("/", (c) => c.html(<Card>content</Card>)); 415 + const res = await app.request("/"); 416 + const html = await res.text(); 417 + expect(html).toContain('class="card"'); 418 + expect(html).toContain("content"); 419 + }); 420 + 421 + it("appends additional class names", async () => { 422 + const app = new Hono().get("/", (c) => 423 + c.html(<Card class="extra">content</Card>) 424 + ); 425 + const res = await app.request("/"); 426 + const html = await res.text(); 427 + expect(html).toContain('class="card extra"'); 428 + }); 429 + }); 430 + ``` 431 + 432 + **Step 2: Run to verify fail** 433 + 434 + ```bash 435 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/components/__tests__/card.test.tsx 436 + ``` 437 + 438 + **Step 3: Implement** 439 + 440 + ```tsx 441 + // apps/web/src/components/card.tsx 442 + import type { FC, PropsWithChildren } from "hono/jsx"; 443 + 444 + interface CardProps { 445 + class?: string; 446 + } 447 + 448 + export const Card: FC<PropsWithChildren<CardProps>> = ({ children, class: className }) => { 449 + const classes = ["card", className].filter(Boolean).join(" "); 450 + return <div class={classes}>{children}</div>; 451 + }; 452 + ``` 453 + 454 + **Step 4: Run to verify pass** 455 + 456 + ```bash 457 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/components/__tests__/card.test.tsx 458 + ``` 459 + 460 + **Step 5: Commit** 461 + 462 + ```bash 463 + git add apps/web/src/components/card.tsx apps/web/src/components/__tests__/card.test.tsx 464 + git commit -m "feat(web): add Card component" 465 + ``` 466 + 467 + --- 468 + 469 + ### Task 6: Button component 470 + 471 + **Files:** 472 + - Create: `apps/web/src/components/__tests__/button.test.tsx` 473 + - Create: `apps/web/src/components/button.tsx` 474 + 475 + **Step 1: Write the failing test** 476 + 477 + ```typescript 478 + // apps/web/src/components/__tests__/button.test.tsx 479 + import { describe, it, expect } from "vitest"; 480 + import { Hono } from "hono"; 481 + import { Button } from "../button.js"; 482 + 483 + describe("Button", () => { 484 + it("renders as <button> when no href provided", async () => { 485 + const app = new Hono().get("/", (c) => 486 + c.html(<Button variant="primary">Click</Button>) 487 + ); 488 + const res = await app.request("/"); 489 + const html = await res.text(); 490 + expect(html).toContain("<button"); 491 + expect(html).toContain("btn-primary"); 492 + expect(html).toContain("Click"); 493 + }); 494 + 495 + it("renders as <a> when href provided", async () => { 496 + const app = new Hono().get("/", (c) => 497 + c.html(<Button variant="secondary" href="/boards">Go</Button>) 498 + ); 499 + const res = await app.request("/"); 500 + const html = await res.text(); 501 + expect(html).toContain("<a "); 502 + expect(html).toContain('href="/boards"'); 503 + expect(html).toContain("btn-secondary"); 504 + }); 505 + 506 + it("renders danger variant", async () => { 507 + const app = new Hono().get("/", (c) => 508 + c.html(<Button variant="danger">Delete</Button>) 509 + ); 510 + const res = await app.request("/"); 511 + const html = await res.text(); 512 + expect(html).toContain("btn-danger"); 513 + }); 514 + 515 + it("includes base btn class on all variants", async () => { 516 + const app = new Hono().get("/", (c) => 517 + c.html(<Button variant="primary">x</Button>) 518 + ); 519 + const res = await app.request("/"); 520 + const html = await res.text(); 521 + expect(html).toMatch(/class="btn /); 522 + }); 523 + }); 524 + ``` 525 + 526 + **Step 2: Run to verify fail** 527 + 528 + ```bash 529 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/components/__tests__/button.test.tsx 530 + ``` 531 + 532 + **Step 3: Implement** 533 + 534 + ```tsx 535 + // apps/web/src/components/button.tsx 536 + import type { FC, PropsWithChildren } from "hono/jsx"; 537 + 538 + type ButtonVariant = "primary" | "secondary" | "danger"; 539 + 540 + interface ButtonProps { 541 + variant: ButtonVariant; 542 + href?: string; 543 + type?: "button" | "submit" | "reset"; 544 + } 545 + 546 + export const Button: FC<PropsWithChildren<ButtonProps>> = ({ 547 + variant, 548 + href, 549 + type = "button", 550 + children, 551 + }) => { 552 + const classes = `btn btn-${variant}`; 553 + if (href) { 554 + return <a href={href} class={classes}>{children}</a>; 555 + } 556 + return <button type={type} class={classes}>{children}</button>; 557 + }; 558 + ``` 559 + 560 + **Step 4: Run to verify pass** 561 + 562 + ```bash 563 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/components/__tests__/button.test.tsx 564 + ``` 565 + 566 + **Step 5: Commit** 567 + 568 + ```bash 569 + git add apps/web/src/components/button.tsx apps/web/src/components/__tests__/button.test.tsx 570 + git commit -m "feat(web): add Button component with primary/secondary/danger variants" 571 + ``` 572 + 573 + --- 574 + 575 + ### Task 7: PageHeader component 576 + 577 + **Files:** 578 + - Create: `apps/web/src/components/__tests__/page-header.test.tsx` 579 + - Create: `apps/web/src/components/page-header.tsx` 580 + 581 + **Step 1: Write the failing test** 582 + 583 + ```typescript 584 + // apps/web/src/components/__tests__/page-header.test.tsx 585 + import { describe, it, expect } from "vitest"; 586 + import { Hono } from "hono"; 587 + import { PageHeader } from "../page-header.js"; 588 + 589 + describe("PageHeader", () => { 590 + it("renders title in h1 with page-header class", async () => { 591 + const app = new Hono().get("/", (c) => 592 + c.html(<PageHeader title="My Page" />) 593 + ); 594 + const res = await app.request("/"); 595 + const html = await res.text(); 596 + expect(html).toContain('class="page-header"'); 597 + expect(html).toContain("<h1"); 598 + expect(html).toContain("My Page"); 599 + }); 600 + 601 + it("renders description when provided", async () => { 602 + const app = new Hono().get("/", (c) => 603 + c.html(<PageHeader title="T" description="A description" />) 604 + ); 605 + const res = await app.request("/"); 606 + const html = await res.text(); 607 + expect(html).toContain("A description"); 608 + }); 609 + 610 + it("omits description element when not provided", async () => { 611 + const app = new Hono().get("/", (c) => 612 + c.html(<PageHeader title="T" />) 613 + ); 614 + const res = await app.request("/"); 615 + const html = await res.text(); 616 + // No stray <p> tags when description is absent 617 + expect(html).not.toMatch(/<p[^>]*>\s*<\/p>/); 618 + }); 619 + 620 + it("renders action slot when provided", async () => { 621 + const app = new Hono().get("/", (c) => 622 + c.html( 623 + <PageHeader title="T" action={<button>New Topic</button>} /> 624 + ) 625 + ); 626 + const res = await app.request("/"); 627 + const html = await res.text(); 628 + expect(html).toContain("New Topic"); 629 + }); 630 + }); 631 + ``` 632 + 633 + **Step 2: Run to verify fail** 634 + 635 + ```bash 636 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/components/__tests__/page-header.test.tsx 637 + ``` 638 + 639 + **Step 3: Implement** 640 + 641 + ```tsx 642 + // apps/web/src/components/page-header.tsx 643 + import type { FC, Child } from "hono/jsx"; 644 + 645 + interface PageHeaderProps { 646 + title: string; 647 + description?: string; 648 + action?: Child; 649 + } 650 + 651 + export const PageHeader: FC<PageHeaderProps> = ({ title, description, action }) => ( 652 + <div class="page-header"> 653 + <div class="page-header__text"> 654 + <h1>{title}</h1> 655 + {description && <p>{description}</p>} 656 + </div> 657 + {action && <div class="page-header__action">{action}</div>} 658 + </div> 659 + ); 660 + ``` 661 + 662 + **Step 4: Run to verify pass** 663 + 664 + ```bash 665 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/components/__tests__/page-header.test.tsx 666 + ``` 667 + 668 + **Step 5: Commit** 669 + 670 + ```bash 671 + git add apps/web/src/components/page-header.tsx apps/web/src/components/__tests__/page-header.test.tsx 672 + git commit -m "feat(web): add PageHeader component" 673 + ``` 674 + 675 + --- 676 + 677 + ### Task 8: ErrorDisplay, EmptyState, LoadingState 678 + 679 + These three are small enough to group in one TDD cycle. 680 + 681 + **Files:** 682 + - Create: `apps/web/src/components/__tests__/utility-components.test.tsx` 683 + - Create: `apps/web/src/components/error-display.tsx` 684 + - Create: `apps/web/src/components/empty-state.tsx` 685 + - Create: `apps/web/src/components/loading-state.tsx` 686 + 687 + **Step 1: Write the failing tests** 688 + 689 + ```typescript 690 + // apps/web/src/components/__tests__/utility-components.test.tsx 691 + import { describe, it, expect } from "vitest"; 692 + import { Hono } from "hono"; 693 + import { ErrorDisplay } from "../error-display.js"; 694 + import { EmptyState } from "../empty-state.js"; 695 + import { LoadingState } from "../loading-state.js"; 696 + 697 + describe("ErrorDisplay", () => { 698 + it("renders message with error-display class", async () => { 699 + const app = new Hono().get("/", (c) => 700 + c.html(<ErrorDisplay message="Something went wrong" />) 701 + ); 702 + const res = await app.request("/"); 703 + const html = await res.text(); 704 + expect(html).toContain('class="error-display"'); 705 + expect(html).toContain("Something went wrong"); 706 + }); 707 + 708 + it("renders detail when provided", async () => { 709 + const app = new Hono().get("/", (c) => 710 + c.html(<ErrorDisplay message="Error" detail="Extra context" />) 711 + ); 712 + const res = await app.request("/"); 713 + const html = await res.text(); 714 + expect(html).toContain("Extra context"); 715 + }); 716 + }); 717 + 718 + describe("EmptyState", () => { 719 + it("renders message with empty-state class", async () => { 720 + const app = new Hono().get("/", (c) => 721 + c.html(<EmptyState message="No topics yet" />) 722 + ); 723 + const res = await app.request("/"); 724 + const html = await res.text(); 725 + expect(html).toContain('class="empty-state"'); 726 + expect(html).toContain("No topics yet"); 727 + }); 728 + 729 + it("renders action when provided", async () => { 730 + const app = new Hono().get("/", (c) => 731 + c.html( 732 + <EmptyState message="Empty" action={<a href="/new">Create one</a>} /> 733 + ) 734 + ); 735 + const res = await app.request("/"); 736 + const html = await res.text(); 737 + expect(html).toContain("Create one"); 738 + }); 739 + }); 740 + 741 + describe("LoadingState", () => { 742 + it("renders with htmx-indicator class", async () => { 743 + const app = new Hono().get("/", (c) => c.html(<LoadingState />)); 744 + const res = await app.request("/"); 745 + const html = await res.text(); 746 + expect(html).toContain("htmx-indicator"); 747 + }); 748 + 749 + it("renders default loading message", async () => { 750 + const app = new Hono().get("/", (c) => c.html(<LoadingState />)); 751 + const res = await app.request("/"); 752 + const html = await res.text(); 753 + expect(html).toContain("Loading"); 754 + }); 755 + 756 + it("renders custom message when provided", async () => { 757 + const app = new Hono().get("/", (c) => 758 + c.html(<LoadingState message="Fetching posts…" />) 759 + ); 760 + const res = await app.request("/"); 761 + const html = await res.text(); 762 + expect(html).toContain("Fetching posts"); 763 + }); 764 + }); 765 + ``` 766 + 767 + **Step 2: Run to verify fail** 768 + 769 + ```bash 770 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/components/__tests__/utility-components.test.tsx 771 + ``` 772 + 773 + **Step 3: Implement all three** 774 + 775 + ```tsx 776 + // apps/web/src/components/error-display.tsx 777 + import type { FC } from "hono/jsx"; 778 + 779 + interface ErrorDisplayProps { 780 + message: string; 781 + detail?: string; 782 + } 783 + 784 + export const ErrorDisplay: FC<ErrorDisplayProps> = ({ message, detail }) => ( 785 + <div class="error-display"> 786 + <p class="error-display__message">{message}</p> 787 + {detail && <p class="error-display__detail">{detail}</p>} 788 + </div> 789 + ); 790 + ``` 791 + 792 + ```tsx 793 + // apps/web/src/components/empty-state.tsx 794 + import type { FC, Child } from "hono/jsx"; 795 + 796 + interface EmptyStateProps { 797 + message: string; 798 + action?: Child; 799 + } 800 + 801 + export const EmptyState: FC<EmptyStateProps> = ({ message, action }) => ( 802 + <div class="empty-state"> 803 + <p>{message}</p> 804 + {action && <div class="empty-state__action">{action}</div>} 805 + </div> 806 + ); 807 + ``` 808 + 809 + ```tsx 810 + // apps/web/src/components/loading-state.tsx 811 + import type { FC } from "hono/jsx"; 812 + 813 + interface LoadingStateProps { 814 + message?: string; 815 + } 816 + 817 + export const LoadingState: FC<LoadingStateProps> = ({ message = "Loading…" }) => ( 818 + <div class="loading-state htmx-indicator"> 819 + <span>{message}</span> 820 + </div> 821 + ); 822 + ``` 823 + 824 + **Step 4: Run to verify pass** 825 + 826 + ```bash 827 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/components/__tests__/utility-components.test.tsx 828 + ``` 829 + 830 + **Step 5: Commit** 831 + 832 + ```bash 833 + git add apps/web/src/components/error-display.tsx apps/web/src/components/empty-state.tsx apps/web/src/components/loading-state.tsx apps/web/src/components/__tests__/utility-components.test.tsx 834 + git commit -m "feat(web): add ErrorDisplay, EmptyState, LoadingState components" 835 + ``` 836 + 837 + --- 838 + 839 + ### Task 9: Component barrel export 840 + 841 + **Files:** 842 + - Create: `apps/web/src/components/index.ts` 843 + 844 + No separate test — covered implicitly by the route stub tests (Task 10) importing from the barrel. 845 + 846 + **Step 1: Create the barrel** 847 + 848 + ```typescript 849 + // apps/web/src/components/index.ts 850 + export { Card } from "./card.js"; 851 + export { Button } from "./button.js"; 852 + export { PageHeader } from "./page-header.js"; 853 + export { ErrorDisplay } from "./error-display.js"; 854 + export { EmptyState } from "./empty-state.js"; 855 + export { LoadingState } from "./loading-state.js"; 856 + ``` 857 + 858 + **Step 2: Commit** 859 + 860 + ```bash 861 + git add apps/web/src/components/index.ts 862 + git commit -m "feat(web): add component barrel export" 863 + ``` 864 + 865 + --- 866 + 867 + ### Task 10: Route stubs 868 + 869 + **Files:** 870 + - Create: `apps/web/src/routes/__tests__/stubs.test.tsx` 871 + - Modify: `apps/web/src/routes/home.tsx` 872 + - Create: `apps/web/src/routes/boards.tsx` 873 + - Create: `apps/web/src/routes/topics.tsx` 874 + - Create: `apps/web/src/routes/login.tsx` 875 + - Create: `apps/web/src/routes/new-topic.tsx` 876 + 877 + **Step 1: Write the failing tests** 878 + 879 + ```typescript 880 + // apps/web/src/routes/__tests__/stubs.test.tsx 881 + import { describe, it, expect } from "vitest"; 882 + import { homeRoutes } from "../home.js"; 883 + import { boardsRoutes } from "../boards.js"; 884 + import { topicsRoutes } from "../topics.js"; 885 + import { loginRoutes } from "../login.js"; 886 + import { newTopicRoutes } from "../new-topic.js"; 887 + 888 + describe("Route stubs", () => { 889 + it("GET / returns 200 with home title", async () => { 890 + const res = await homeRoutes.request("/"); 891 + expect(res.status).toBe(200); 892 + const html = await res.text(); 893 + expect(html).toContain("Home — atBB Forum"); 894 + }); 895 + 896 + it("GET /boards/:id returns 200 with board title", async () => { 897 + const res = await boardsRoutes.request("/boards/123"); 898 + expect(res.status).toBe(200); 899 + const html = await res.text(); 900 + expect(html).toContain("Board — atBB Forum"); 901 + }); 902 + 903 + it("GET /topics/:id returns 200 with topic title", async () => { 904 + const res = await topicsRoutes.request("/topics/123"); 905 + expect(res.status).toBe(200); 906 + const html = await res.text(); 907 + expect(html).toContain("Topic — atBB Forum"); 908 + }); 909 + 910 + it("GET /login returns 200 with login title", async () => { 911 + const res = await loginRoutes.request("/login"); 912 + expect(res.status).toBe(200); 913 + const html = await res.text(); 914 + expect(html).toContain("Login — atBB Forum"); 915 + }); 916 + 917 + it("GET /new-topic returns 200 with new topic title", async () => { 918 + const res = await newTopicRoutes.request("/new-topic"); 919 + expect(res.status).toBe(200); 920 + const html = await res.text(); 921 + expect(html).toContain("New Topic — atBB Forum"); 922 + }); 923 + }); 924 + ``` 925 + 926 + **Step 2: Run to verify fail** 927 + 928 + ```bash 929 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/routes/__tests__/stubs.test.tsx 930 + ``` 931 + Expected: FAIL — route files don't exist yet 932 + 933 + **Step 3: Implement all routes** 934 + 935 + ```tsx 936 + // apps/web/src/routes/home.tsx 937 + import { Hono } from "hono"; 938 + import { BaseLayout } from "../layouts/base.js"; 939 + import { PageHeader, EmptyState } from "../components/index.js"; 940 + 941 + export const homeRoutes = new Hono().get("/", (c) => 942 + c.html( 943 + <BaseLayout title="Home — atBB Forum"> 944 + <PageHeader 945 + title="Welcome to atBB" 946 + description="A BB-style forum on the ATmosphere." 947 + /> 948 + <EmptyState message="No boards yet." /> 949 + </BaseLayout> 950 + ) 951 + ); 952 + ``` 953 + 954 + ```tsx 955 + // apps/web/src/routes/boards.tsx 956 + import { Hono } from "hono"; 957 + import { BaseLayout } from "../layouts/base.js"; 958 + import { PageHeader, EmptyState } from "../components/index.js"; 959 + 960 + export const boardsRoutes = new Hono().get("/boards/:id", (c) => 961 + c.html( 962 + <BaseLayout title="Board — atBB Forum"> 963 + <PageHeader title="Board" description="Topics will appear here." /> 964 + <EmptyState message="No topics yet." /> 965 + </BaseLayout> 966 + ) 967 + ); 968 + ``` 969 + 970 + ```tsx 971 + // apps/web/src/routes/topics.tsx 972 + import { Hono } from "hono"; 973 + import { BaseLayout } from "../layouts/base.js"; 974 + import { PageHeader, EmptyState } from "../components/index.js"; 975 + 976 + export const topicsRoutes = new Hono().get("/topics/:id", (c) => 977 + c.html( 978 + <BaseLayout title="Topic — atBB Forum"> 979 + <PageHeader title="Topic" /> 980 + <EmptyState message="No replies yet." /> 981 + </BaseLayout> 982 + ) 983 + ); 984 + ``` 985 + 986 + ```tsx 987 + // apps/web/src/routes/login.tsx 988 + import { Hono } from "hono"; 989 + import { BaseLayout } from "../layouts/base.js"; 990 + import { PageHeader } from "../components/index.js"; 991 + 992 + export const loginRoutes = new Hono().get("/login", (c) => 993 + c.html( 994 + <BaseLayout title="Login — atBB Forum"> 995 + <PageHeader 996 + title="Sign in" 997 + description="Sign in with your AT Protocol account." 998 + /> 999 + </BaseLayout> 1000 + ) 1001 + ); 1002 + ``` 1003 + 1004 + ```tsx 1005 + // apps/web/src/routes/new-topic.tsx 1006 + import { Hono } from "hono"; 1007 + import { BaseLayout } from "../layouts/base.js"; 1008 + import { PageHeader } from "../components/index.js"; 1009 + 1010 + export const newTopicRoutes = new Hono().get("/new-topic", (c) => 1011 + c.html( 1012 + <BaseLayout title="New Topic — atBB Forum"> 1013 + <PageHeader title="New Topic" description="Compose a new topic." /> 1014 + </BaseLayout> 1015 + ) 1016 + ); 1017 + ``` 1018 + 1019 + **Step 4: Run to verify pass** 1020 + 1021 + ```bash 1022 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/routes/__tests__/stubs.test.tsx 1023 + ``` 1024 + Expected: PASS (5 tests) 1025 + 1026 + **Step 5: Commit** 1027 + 1028 + ```bash 1029 + git add apps/web/src/routes/home.tsx apps/web/src/routes/boards.tsx apps/web/src/routes/topics.tsx apps/web/src/routes/login.tsx apps/web/src/routes/new-topic.tsx apps/web/src/routes/__tests__/stubs.test.tsx 1030 + git commit -m "feat(web): add route stubs for boards, topics, login, new-topic" 1031 + ``` 1032 + 1033 + --- 1034 + 1035 + ### Task 11: Register all routes + final verification 1036 + 1037 + **Files:** 1038 + - Modify: `apps/web/src/routes/index.ts` 1039 + 1040 + **Step 1: Update route registration** 1041 + 1042 + ```typescript 1043 + // apps/web/src/routes/index.ts 1044 + import { Hono } from "hono"; 1045 + import { homeRoutes } from "./home.js"; 1046 + import { boardsRoutes } from "./boards.js"; 1047 + import { topicsRoutes } from "./topics.js"; 1048 + import { loginRoutes } from "./login.js"; 1049 + import { newTopicRoutes } from "./new-topic.js"; 1050 + 1051 + export const webRoutes = new Hono() 1052 + .route("/", homeRoutes) 1053 + .route("/", boardsRoutes) 1054 + .route("/", topicsRoutes) 1055 + .route("/", loginRoutes) 1056 + .route("/", newTopicRoutes); 1057 + ``` 1058 + 1059 + **Step 2: Run full web test suite** 1060 + 1061 + ```bash 1062 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test 1063 + ``` 1064 + Expected: All tests pass. 1065 + 1066 + **Step 3: Run full monorepo test suite** 1067 + 1068 + ```bash 1069 + PATH=.devenv/profile/bin:$PATH pnpm test 1070 + ``` 1071 + Expected: All tests pass across all packages. 1072 + 1073 + **Step 4: Typecheck** 1074 + 1075 + ```bash 1076 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web lint 1077 + ``` 1078 + Expected: No errors. 1079 + 1080 + **Step 5: Commit** 1081 + 1082 + ```bash 1083 + git add apps/web/src/routes/index.ts 1084 + git commit -m "feat(web): register all route stubs in router" 1085 + ``` 1086 + 1087 + --- 1088 + 1089 + ## Done 1090 + 1091 + At completion: 1092 + - All design tokens inject into every page as a `:root { }` style block 1093 + - `reset.css` and `theme.css` serve from `/static/css/` — `theme.css` uses only `var(--token)` references 1094 + - Six shared components available from `apps/web/src/components/index.ts` 1095 + - Five route stubs return 200 with consistent `BaseLayout` wrapping 1096 + - All tests pass via `pnpm test` 1097 + 1098 + Update Linear ATB-26 status to **In Progress**. Request code review when complete.
+172
docs/plans/2026-02-17-web-ui-foundation-design.md
··· 1 + # ATB-26: Web UI Foundation Design 2 + 3 + **Date:** 2026-02-17 4 + **Status:** Approved 5 + **Linear:** [ATB-26](https://linear.app/atbb/issue/ATB-26) 6 + **Phase:** 4 — Web UI (Theme Phase 1) 7 + 8 + --- 9 + 10 + ## Goal 11 + 12 + Build the visual foundation the web app needs before any other Phase 4 work can proceed: a neobrutal CSS design system, shared JSX components, semantic layout, and static file serving. 13 + 14 + --- 15 + 16 + ## File Structure 17 + 18 + ``` 19 + apps/web/ 20 + public/ 21 + css/ 22 + reset.css ← minimal normalize/reset 23 + theme.css ← all component styles via CSS custom properties 24 + src/ 25 + components/ 26 + card.tsx 27 + button.tsx 28 + page-header.tsx 29 + error-display.tsx 30 + empty-state.tsx 31 + loading-state.tsx 32 + index.ts ← barrel re-export 33 + styles/ 34 + presets/ 35 + neobrutal-light.ts ← typed token constant (37 tokens) 36 + lib/ 37 + theme.ts ← tokensToCss() utility 38 + layouts/ 39 + base.tsx ← updated with tokens, fonts, CSS links, semantic HTML 40 + routes/ 41 + home.tsx ← updated to use new components 42 + boards.tsx ← new: GET /boards/:id stub 43 + topics.tsx ← new: GET /topics/:id stub 44 + login.tsx ← new: GET /login stub 45 + new-topic.tsx ← new: GET /new-topic stub 46 + index.ts ← updated to register new routes 47 + index.ts ← updated with serveStatic middleware 48 + ``` 49 + 50 + --- 51 + 52 + ## CSS Architecture 53 + 54 + ### Layer order 55 + 56 + ``` 57 + Layer 0: public/css/reset.css — box-sizing, margin/padding reset, font inheritance 58 + Layer 1: public/css/theme.css — component styles using only var(--token) references 59 + Layer 2: <style>:root { ... }</style> — token values injected by BaseLayout at render time 60 + ``` 61 + 62 + The `:root` style block renders **before** the `<link>` to `theme.css` so properties are defined when the stylesheet loads. 63 + 64 + ### Token preset 65 + 66 + `src/styles/presets/neobrutal-light.ts` exports a `Record<string, string>` of all 37 tokens from the theming plan. Keys map directly to CSS custom property names (prepend `--` to get `--color-bg`). Using TypeScript (not JSON) avoids ESM import assertion complexity; the shape matches the future `space.atbb.forum.theme` record format and can be serialized to JSON when Phase 2 needs it. 67 + 68 + ### Token injection utility 69 + 70 + `src/lib/theme.ts` exports `tokensToCss(tokens: Record<string, string>): string`, which returns a semicolon-separated string of `--key: value` declarations suitable for embedding in a `:root { }` block. 71 + 72 + --- 73 + 74 + ## BaseLayout Updates 75 + 76 + ```tsx 77 + <html lang="en"> 78 + <head> 79 + <meta charset="UTF-8" /> 80 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 81 + <title>{props.title ?? "atBB Forum"}</title> 82 + <style>{`:root { ${tokensToCss(neobrutalLight)} }`}</style> 83 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 84 + <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap" /> 85 + <link rel="stylesheet" href="/static/css/reset.css" /> 86 + <link rel="stylesheet" href="/static/css/theme.css" /> 87 + <script src="https://unpkg.com/htmx.org@2.0.4" defer /> 88 + </head> 89 + <body> 90 + <header class="site-header"> 91 + <div class="site-header__inner"> 92 + <a href="/" class="site-header__title">atBB Forum</a> 93 + <nav class="site-header__nav">{/* login/logout — auth ticket */}</nav> 94 + </div> 95 + </header> 96 + <main class="content-container">{props.children}</main> 97 + <footer class="site-footer"> 98 + <p>Powered by atBB on the ATmosphere</p> 99 + </footer> 100 + </body> 101 + </html> 102 + ``` 103 + 104 + --- 105 + 106 + ## Static File Serving 107 + 108 + Add `serveStatic` from `@hono/node-server/serve-static` to `apps/web/src/index.ts`, registered **before** page routes: 109 + 110 + ```ts 111 + import { serveStatic } from "@hono/node-server/serve-static"; 112 + app.use("/static/*", serveStatic({ root: "./public" })); 113 + ``` 114 + 115 + CSS files live in `apps/web/public/css/` and serve at `/static/css/reset.css` and `/static/css/theme.css`. No new dependencies — `serveStatic` ships with `@hono/node-server`. 116 + 117 + --- 118 + 119 + ## Shared Components 120 + 121 + All components are purely presentational. Each lives in its own file under `src/components/` and re-exports from `index.ts`. 122 + 123 + | Component | Props | Renders | 124 + |---|---|---| 125 + | `Card` | `children`, optional `class` | `<div class="card">` | 126 + | `Button` | `variant` (`primary`\|`secondary`\|`danger`), `href?`, `type?`, `children` | `<a>` if `href` set, `<button>` otherwise | 127 + | `PageHeader` | `title`, `description?`, `action?` (JSX node) | `<div class="page-header">` with `<h1>`, optional desc, optional action slot | 128 + | `ErrorDisplay` | `message`, `detail?` | Danger-bordered box for user-facing errors | 129 + | `EmptyState` | `message`, `action?` (JSX node) | Centered placeholder for empty lists | 130 + | `LoadingState` | `message?` | `htmx-indicator`-compatible loading text/indicator | 131 + 132 + `Button` renders as `<a>` when `href` is provided and `<button>` otherwise — enforcing correct semantics (navigation vs. form action) at the component level. 133 + 134 + --- 135 + 136 + ## Route Stubs 137 + 138 + Each stub returns 200 with `BaseLayout` and a `PageHeader`. Real content comes in later Phase 4 tickets. 139 + 140 + | Route | File | Page title | 141 + |---|---|---| 142 + | `GET /` | `home.tsx` | Home — atBB Forum | 143 + | `GET /boards/:id` | `boards.tsx` | Board — atBB Forum | 144 + | `GET /topics/:id` | `topics.tsx` | Topic — atBB Forum | 145 + | `GET /login` | `login.tsx` | Login — atBB Forum | 146 + | `GET /new-topic` | `new-topic.tsx` | New Topic — atBB Forum | 147 + 148 + `routes/index.ts` registers all five route modules. 149 + 150 + --- 151 + 152 + ## Testing 153 + 154 + All tests use the `app.request()` pattern from the existing test suite. 155 + 156 + | Test | What it checks | 157 + |---|---| 158 + | Component rendering | Create an inline Hono app per test, render component via `c.html()`, assert HTML structure with `toContain()` | 159 + | Token injection | `BaseLayout` output contains `--color-bg` and `--color-primary` in a `<style>` block | 160 + | `tokensToCss()` unit test | Converts a token map to a valid CSS custom property string | 161 + | Route stubs | Each route returns 200 with the correct `<title>` in the HTML response | 162 + | Static file serving | Register `serveStatic` in a test app pointing at `./public`, request `/static/css/reset.css`, verify 200 + `text/css` content-type | 163 + 164 + --- 165 + 166 + ## Implementation Notes 167 + 168 + - Use `frontend-design:frontend-design` skill to drive the actual CSS and component visual implementation. 169 + - The neobrutal token values come from `docs/theming-plan.md` lines 62–121 verbatim. 170 + - `theme.css` must reference **only** `var(--token)` values — no hardcoded colors or sizes anywhere in the file. 171 + - No CSS framework. No Tailwind, Pico, or Bootstrap. 172 + - The `<style>:root { }` block goes before the `<link>` to `theme.css` — order matters.