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
1import { describe, it, expect } from "vitest";
2import { Hono } from "hono";
3import { BaseLayout } from "../base.js";
4import type { WebSession } from "../../lib/session.js";
5import { FALLBACK_THEME } from "../../lib/theme-resolution.js";
6
7const app = new Hono().get("/", (c) =>
8 c.html(
9 <BaseLayout title="Test Page" resolvedTheme={FALLBACK_THEME}>
10 Page content
11 </BaseLayout>
12 )
13);
14
15describe("BaseLayout", () => {
16 it("injects neobrutal tokens as :root CSS custom properties", async () => {
17 const res = await app.request("/");
18 const html = await res.text();
19 // css-tree generates compact CSS (no space before brace)
20 expect(html).toContain(":root{");
21 expect(html).toContain("--color-bg:");
22 expect(html).toContain("--color-primary:");
23 });
24
25 it("loads reset.css and theme.css stylesheets", async () => {
26 const res = await app.request("/");
27 const html = await res.text();
28 expect(html).toContain('href="/static/css/reset.css"');
29 expect(html).toContain('href="/static/css/theme.css"');
30 });
31
32 it("loads Space Grotesk from Google Fonts", async () => {
33 const res = await app.request("/");
34 const html = await res.text();
35 expect(html).toContain("fonts.googleapis.com");
36 expect(html).toContain("Space+Grotesk");
37 });
38
39 it("renders semantic site-header, content-container, and site-footer", async () => {
40 const res = await app.request("/");
41 const html = await res.text();
42 expect(html).toContain('class="site-header"');
43 expect(html).toContain('class="content-container"');
44 expect(html).toContain('class="site-footer"');
45 });
46
47 it("renders provided page title", async () => {
48 const res = await app.request("/");
49 const html = await res.text();
50 expect(html).toContain("<title>Test Page</title>");
51 });
52
53 it("falls back to default title when none provided", async () => {
54 const defaultApp = new Hono().get("/", (c) =>
55 c.html(
56 <BaseLayout resolvedTheme={FALLBACK_THEME}>content</BaseLayout>
57 )
58 );
59 const res = await defaultApp.request("/");
60 const html = await res.text();
61 expect(html).toContain("<title>atBB Forum</title>");
62 });
63
64 it("renders children inside content-container", async () => {
65 const res = await app.request("/");
66 const html = await res.text();
67 expect(html).toContain("Page content");
68 });
69
70 it("renders header title link pointing to /", async () => {
71 const res = await app.request("/");
72 const html = await res.text();
73 expect(html).toContain('href="/"');
74 expect(html).toContain('class="site-header__title"');
75 });
76
77 it("includes Accept-CH meta tag for color scheme hint", async () => {
78 const res = await app.request("/");
79 const html = await res.text();
80 expect(html).toContain('http-equiv="Accept-CH"');
81 expect(html).toContain('content="Sec-CH-Prefers-Color-Scheme"');
82 });
83
84 it("renders cssOverrides in a style tag when non-null", async () => {
85 const themeWithOverrides = {
86 ...FALLBACK_THEME,
87 cssOverrides: ".card { border: 2px solid black; }",
88 };
89 const overridesApp = new Hono().get("/", (c) =>
90 c.html(
91 <BaseLayout resolvedTheme={themeWithOverrides}>content</BaseLayout>
92 )
93 );
94 const res = await overridesApp.request("/");
95 const html = await res.text();
96 // css-tree generates compact CSS — check for key selectors and properties
97 expect(html).toContain(".card{");
98 expect(html).toContain("border:2px solid black");
99 });
100
101 it("does not render Google Fonts preconnect tags when fontUrls is null", async () => {
102 const themeNoFonts = { ...FALLBACK_THEME, fontUrls: null };
103 const noFontsApp = new Hono().get("/", (c) =>
104 c.html(
105 <BaseLayout resolvedTheme={themeNoFonts}>content</BaseLayout>
106 )
107 );
108 const res = await noFontsApp.request("/");
109 const html = await res.text();
110 expect(html).not.toContain("fonts.googleapis.com");
111 });
112
113 it("filters out non-https font URLs and does not render them", async () => {
114 const themeWithUnsafeFontUrl = {
115 ...FALLBACK_THEME,
116 fontUrls: ["http://evil.com/style.css", "https://fonts.example.com/safe.css"],
117 };
118 const unsafeFontApp = new Hono().get("/", (c) =>
119 c.html(
120 <BaseLayout resolvedTheme={themeWithUnsafeFontUrl}>content</BaseLayout>
121 )
122 );
123 const res = await unsafeFontApp.request("/");
124 const html = await res.text();
125 expect(html).not.toContain("http://evil.com/style.css");
126 expect(html).toContain("https://fonts.example.com/safe.css");
127 });
128
129 it("does not render cssOverrides style tag when cssOverrides is null", async () => {
130 const themeNoOverrides = { ...FALLBACK_THEME, cssOverrides: null };
131 const noOverridesApp = new Hono().get("/", (c) =>
132 c.html(
133 <BaseLayout resolvedTheme={themeNoOverrides}>content</BaseLayout>
134 )
135 );
136 const res = await noOverridesApp.request("/");
137 const html = await res.text();
138 // The only <style> tag should be the :root block — no second style tag for overrides
139 const styleTagMatches = html.match(/<style/g);
140 expect(styleTagMatches).toHaveLength(1);
141 // css-tree generates compact CSS (no space before brace)
142 expect(html).toContain(":root{");
143 });
144
145 describe("auth-aware navigation", () => {
146 it("shows Log in link when auth is not provided (default unauthenticated)", async () => {
147 const unauthApp = new Hono().get("/", (c) =>
148 c.html(
149 <BaseLayout resolvedTheme={FALLBACK_THEME}>content</BaseLayout>
150 )
151 );
152 const res = await unauthApp.request("/");
153 const html = await res.text();
154 expect(html).toContain('href="/login"');
155 expect(html).toContain("Log in");
156 });
157
158 it("shows Log in link when auth is explicitly unauthenticated", async () => {
159 const auth: WebSession = { authenticated: false };
160 const unauthApp = new Hono().get("/", (c) =>
161 c.html(
162 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}>
163 content
164 </BaseLayout>
165 )
166 );
167 const res = await unauthApp.request("/");
168 const html = await res.text();
169 expect(html).toContain('href="/login"');
170 expect(html).toContain("Log in");
171 expect(html).not.toContain("Log out");
172 });
173
174 it("shows handle and Log out button when authenticated", async () => {
175 const auth: WebSession = {
176 authenticated: true,
177 did: "did:plc:abc123",
178 handle: "alice.bsky.social",
179 };
180 const authApp = new Hono().get("/", (c) =>
181 c.html(
182 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}>
183 content
184 </BaseLayout>
185 )
186 );
187 const res = await authApp.request("/");
188 const html = await res.text();
189 expect(html).toContain("alice.bsky.social");
190 expect(html).toContain("Log out");
191 expect(html).not.toContain('href="/login"');
192 });
193
194 it("renders logout as a form POST (not a link)", async () => {
195 const auth: WebSession = {
196 authenticated: true,
197 did: "did:plc:abc123",
198 handle: "alice.bsky.social",
199 };
200 const authApp = new Hono().get("/", (c) =>
201 c.html(
202 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}>
203 content
204 </BaseLayout>
205 )
206 );
207 const res = await authApp.request("/");
208 const html = await res.text();
209 // Logout must be a form POST for CSRF protection, not a plain link
210 expect(html).toContain('action="/logout"');
211 expect(html).toContain('method="post"');
212 expect(html).toContain("Log out");
213 });
214 });
215
216 describe("accessibility", () => {
217 it("renders skip-to-content link before the site header", async () => {
218 const res = await app.request("/");
219 const html = await res.text();
220 expect(html).toContain('class="skip-link"');
221 expect(html).toContain('href="#main-content"');
222 expect(html).toContain("Skip to main content");
223 // Skip link must come before header in DOM order
224 const skipLinkPos = html.indexOf("skip-link");
225 const headerPos = html.indexOf("site-header");
226 expect(skipLinkPos).toBeLessThan(headerPos);
227 });
228
229 it("renders main element with id for skip link target", async () => {
230 const res = await app.request("/");
231 const html = await res.text();
232 expect(html).toContain('id="main-content"');
233 });
234
235 it("desktop nav has aria-label for Main navigation", async () => {
236 const res = await app.request("/");
237 const html = await res.text();
238 expect(html).toContain('aria-label="Main navigation"');
239 });
240
241 it("mobile nav has distinct aria-label", async () => {
242 const res = await app.request("/");
243 const html = await res.text();
244 expect(html).toContain('aria-label="Mobile navigation"');
245 });
246 });
247
248 describe("color scheme toggle", () => {
249 it("renders toggle button in site header when color scheme is light", async () => {
250 const res = await app.request("/");
251 const html = await res.text();
252 expect(html).toContain("color-scheme-toggle");
253 expect(html).toContain('aria-label="Switch to dark mode"');
254 });
255
256 it("renders toggle button with aria-label 'Switch to light mode' when dark theme", async () => {
257 const darkTheme = { ...FALLBACK_THEME, colorScheme: "dark" as const };
258 const darkApp = new Hono().get("/", (c) =>
259 c.html(<BaseLayout resolvedTheme={darkTheme}>content</BaseLayout>)
260 );
261 const res = await darkApp.request("/");
262 const html = await res.text();
263 expect(html).toContain("color-scheme-toggle");
264 expect(html).toContain('aria-label="Switch to light mode"');
265 });
266
267 it("toggle button appears in both desktop and mobile nav", async () => {
268 const res = await app.request("/");
269 const html = await res.text();
270 const toggleMatches = html.match(/color-scheme-toggle/g);
271 expect(toggleMatches).toHaveLength(2);
272 });
273
274 it("toggle button calls toggleColorScheme on click", async () => {
275 const res = await app.request("/");
276 const html = await res.text();
277 expect(html).toContain("toggleColorScheme()");
278 });
279
280 it("page includes toggleColorScheme script that sets cookie and reloads", async () => {
281 const res = await app.request("/");
282 const html = await res.text();
283 expect(html).toContain("atbb-color-scheme");
284 expect(html).toContain("location.reload");
285 expect(html).toContain("max-age=31536000");
286 expect(html).toContain("SameSite=Lax");
287 expect(html).toContain("path=/");
288 });
289 });
290
291 describe("favicon", () => {
292 it("includes favicon link in head", async () => {
293 const res = await app.request("/");
294 const html = await res.text();
295 expect(html).toContain('rel="icon"');
296 expect(html).toContain("favicon.svg");
297 });
298 });
299
300 describe("mobile navigation", () => {
301 it("renders details/summary hamburger menu for mobile", async () => {
302 const res = await app.request("/");
303 const html = await res.text();
304 expect(html).toContain("mobile-nav");
305 expect(html).toContain("mobile-nav__toggle");
306 });
307
308 it("renders desktop nav separately from mobile nav", async () => {
309 const res = await app.request("/");
310 const html = await res.text();
311 expect(html).toContain("desktop-nav");
312 });
313
314 it("hamburger has aria-label for accessibility", async () => {
315 const res = await app.request("/");
316 const html = await res.text();
317 expect(html).toContain('aria-label="Menu"');
318 });
319
320 it("mobile nav contains login link when not authenticated", async () => {
321 const res = await app.request("/");
322 const html = await res.text();
323 // Both mobile and desktop nav should have "Log in"
324 const loginMatches = html.match(/Log in/g);
325 expect(loginMatches!.length).toBe(2);
326 });
327
328 it("mobile nav contains auth state when logged in", async () => {
329 const auth: WebSession = {
330 authenticated: true,
331 did: "did:plc:abc123",
332 handle: "alice.bsky.social",
333 };
334 const authApp = new Hono().get("/", (c) =>
335 c.html(
336 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}>
337 content
338 </BaseLayout>
339 )
340 );
341 const res = await authApp.request("/");
342 const html = await res.text();
343 // Both mobile and desktop nav should have "Log out"
344 const logoutMatches = html.match(/Log out/g);
345 expect(logoutMatches!.length).toBe(2);
346 });
347 });
348});