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 it("shows Settings link when authenticated", async () => {
216 const auth: WebSession = {
217 authenticated: true,
218 did: "did:plc:abc123",
219 handle: "alice.bsky.social",
220 };
221 const authApp = new Hono().get("/", (c) =>
222 c.html(
223 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}>
224 content
225 </BaseLayout>
226 )
227 );
228 const res = await authApp.request("/");
229 const html = await res.text();
230 expect(html).toContain('href="/settings"');
231 expect(html).toContain("Settings");
232 });
233
234 it("does not show Settings link when unauthenticated", async () => {
235 const auth: WebSession = { authenticated: false };
236 const unauthApp = new Hono().get("/", (c) =>
237 c.html(
238 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}>
239 content
240 </BaseLayout>
241 )
242 );
243 const res = await unauthApp.request("/");
244 const html = await res.text();
245 expect(html).not.toContain('href="/settings"');
246 expect(html).not.toContain("Settings");
247 });
248 });
249
250 describe("accessibility", () => {
251 it("renders skip-to-content link before the site header", async () => {
252 const res = await app.request("/");
253 const html = await res.text();
254 expect(html).toContain('class="skip-link"');
255 expect(html).toContain('href="#main-content"');
256 expect(html).toContain("Skip to main content");
257 // Skip link must come before header in DOM order
258 const skipLinkPos = html.indexOf("skip-link");
259 const headerPos = html.indexOf("site-header");
260 expect(skipLinkPos).toBeLessThan(headerPos);
261 });
262
263 it("renders main element with id for skip link target", async () => {
264 const res = await app.request("/");
265 const html = await res.text();
266 expect(html).toContain('id="main-content"');
267 });
268
269 it("desktop nav has aria-label for Main navigation", async () => {
270 const res = await app.request("/");
271 const html = await res.text();
272 expect(html).toContain('aria-label="Main navigation"');
273 });
274
275 it("mobile nav has distinct aria-label", async () => {
276 const res = await app.request("/");
277 const html = await res.text();
278 expect(html).toContain('aria-label="Mobile navigation"');
279 });
280 });
281
282 describe("color scheme toggle", () => {
283 it("renders toggle button in site header when color scheme is light", async () => {
284 const res = await app.request("/");
285 const html = await res.text();
286 expect(html).toContain("color-scheme-toggle");
287 expect(html).toContain('aria-label="Switch to dark mode"');
288 });
289
290 it("renders toggle button with aria-label 'Switch to light mode' when dark theme", async () => {
291 const darkTheme = { ...FALLBACK_THEME, colorScheme: "dark" as const };
292 const darkApp = new Hono().get("/", (c) =>
293 c.html(<BaseLayout resolvedTheme={darkTheme}>content</BaseLayout>)
294 );
295 const res = await darkApp.request("/");
296 const html = await res.text();
297 expect(html).toContain("color-scheme-toggle");
298 expect(html).toContain('aria-label="Switch to light mode"');
299 });
300
301 it("toggle button appears in both desktop and mobile nav", async () => {
302 const res = await app.request("/");
303 const html = await res.text();
304 const toggleMatches = html.match(/color-scheme-toggle/g);
305 expect(toggleMatches).toHaveLength(2);
306 });
307
308 it("toggle button calls toggleColorScheme on click", async () => {
309 const res = await app.request("/");
310 const html = await res.text();
311 expect(html).toContain("toggleColorScheme()");
312 });
313
314 it("page includes toggleColorScheme script that sets cookie and reloads", async () => {
315 const res = await app.request("/");
316 const html = await res.text();
317 expect(html).toContain("atbb-color-scheme");
318 expect(html).toContain("location.reload");
319 expect(html).toContain("max-age=31536000");
320 expect(html).toContain("SameSite=Lax");
321 expect(html).toContain("path=/");
322 });
323
324 it("toggleColorScheme script defaults to 'light' when no cookie present (first toggle must produce 'dark')", async () => {
325 // Regression: the old script used `m&&m[1]==='light'` which evaluates to `null`
326 // (not `false`) when no cookie exists, causing the first toggle to always produce
327 // 'light' instead of 'dark'. The fix introduces a `current` variable that defaults
328 // to 'light', ensuring `next` is always the opposite of the current scheme.
329 const res = await app.request("/");
330 const html = await res.text();
331 // Verify the corrected pattern is present: `current` defaults to 'light' when no cookie
332 expect(html).toContain("var current=m?m[1]:'light'");
333 // Verify `next` is derived from `current`, not from the raw regex match
334 expect(html).toContain("current==='light'?'dark':'light'");
335 });
336 });
337
338 describe("favicon", () => {
339 it("includes favicon link in head", async () => {
340 const res = await app.request("/");
341 const html = await res.text();
342 expect(html).toContain('rel="icon"');
343 expect(html).toContain("favicon.svg");
344 });
345 });
346
347 describe("mobile navigation", () => {
348 it("renders details/summary hamburger menu for mobile", async () => {
349 const res = await app.request("/");
350 const html = await res.text();
351 expect(html).toContain("mobile-nav");
352 expect(html).toContain("mobile-nav__toggle");
353 });
354
355 it("renders desktop nav separately from mobile nav", async () => {
356 const res = await app.request("/");
357 const html = await res.text();
358 expect(html).toContain("desktop-nav");
359 });
360
361 it("hamburger has aria-label for accessibility", async () => {
362 const res = await app.request("/");
363 const html = await res.text();
364 expect(html).toContain('aria-label="Menu"');
365 });
366
367 it("mobile nav contains login link when not authenticated", async () => {
368 const res = await app.request("/");
369 const html = await res.text();
370 // Both mobile and desktop nav should have "Log in"
371 const loginMatches = html.match(/Log in/g);
372 expect(loginMatches!.length).toBe(2);
373 });
374
375 it("mobile nav contains auth state when logged in", async () => {
376 const auth: WebSession = {
377 authenticated: true,
378 did: "did:plc:abc123",
379 handle: "alice.bsky.social",
380 };
381 const authApp = new Hono().get("/", (c) =>
382 c.html(
383 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}>
384 content
385 </BaseLayout>
386 )
387 );
388 const res = await authApp.request("/");
389 const html = await res.text();
390 // Both mobile and desktop nav should have "Log out"
391 const logoutMatches = html.match(/Log out/g);
392 expect(logoutMatches!.length).toBe(2);
393 });
394
395 it("renders Settings link in both desktop and mobile nav when authenticated", async () => {
396 const auth: WebSession = {
397 authenticated: true,
398 did: "did:plc:abc123",
399 handle: "alice.bsky.social",
400 };
401 const authApp = new Hono().get("/", (c) =>
402 c.html(
403 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}>
404 content
405 </BaseLayout>
406 )
407 );
408 const res = await authApp.request("/");
409 const html = await res.text();
410 // NavContent is rendered twice (desktop + mobile), so the link appears twice
411 const settingsMatches = [...html.matchAll(/href="\/settings"/g)];
412 expect(settingsMatches).toHaveLength(2);
413 });
414 });
415});