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";
5
6const app = new Hono().get("/", (c) =>
7 c.html(<BaseLayout title="Test Page">Page content</BaseLayout>)
8);
9
10describe("BaseLayout", () => {
11 it("injects neobrutal tokens as :root CSS custom properties", async () => {
12 const res = await app.request("/");
13 const html = await res.text();
14 expect(html).toContain(":root {");
15 expect(html).toContain("--color-bg:");
16 expect(html).toContain("--color-primary:");
17 });
18
19 it("loads reset.css and theme.css stylesheets", async () => {
20 const res = await app.request("/");
21 const html = await res.text();
22 expect(html).toContain('href="/static/css/reset.css"');
23 expect(html).toContain('href="/static/css/theme.css"');
24 });
25
26 it("loads Space Grotesk from Google Fonts", async () => {
27 const res = await app.request("/");
28 const html = await res.text();
29 expect(html).toContain("fonts.googleapis.com");
30 expect(html).toContain("Space+Grotesk");
31 });
32
33 it("renders semantic site-header, content-container, and site-footer", async () => {
34 const res = await app.request("/");
35 const html = await res.text();
36 expect(html).toContain('class="site-header"');
37 expect(html).toContain('class="content-container"');
38 expect(html).toContain('class="site-footer"');
39 });
40
41 it("renders provided page title", async () => {
42 const res = await app.request("/");
43 const html = await res.text();
44 expect(html).toContain("<title>Test Page</title>");
45 });
46
47 it("falls back to default title when none provided", async () => {
48 const defaultApp = new Hono().get("/", (c) =>
49 c.html(<BaseLayout>content</BaseLayout>)
50 );
51 const res = await defaultApp.request("/");
52 const html = await res.text();
53 expect(html).toContain("<title>atBB Forum</title>");
54 });
55
56 it("renders children inside content-container", async () => {
57 const res = await app.request("/");
58 const html = await res.text();
59 expect(html).toContain("Page content");
60 });
61
62 it("renders header title link pointing to /", async () => {
63 const res = await app.request("/");
64 const html = await res.text();
65 expect(html).toContain('href="/"');
66 expect(html).toContain('class="site-header__title"');
67 });
68
69 describe("auth-aware navigation", () => {
70 it("shows Log in link when auth is not provided (default unauthenticated)", async () => {
71 const unauthApp = new Hono().get("/", (c) =>
72 c.html(<BaseLayout>content</BaseLayout>)
73 );
74 const res = await unauthApp.request("/");
75 const html = await res.text();
76 expect(html).toContain('href="/login"');
77 expect(html).toContain("Log in");
78 });
79
80 it("shows Log in link when auth is explicitly unauthenticated", async () => {
81 const auth: WebSession = { authenticated: false };
82 const unauthApp = new Hono().get("/", (c) =>
83 c.html(<BaseLayout auth={auth}>content</BaseLayout>)
84 );
85 const res = await unauthApp.request("/");
86 const html = await res.text();
87 expect(html).toContain('href="/login"');
88 expect(html).toContain("Log in");
89 expect(html).not.toContain("Log out");
90 });
91
92 it("shows handle and Log out button when authenticated", async () => {
93 const auth: WebSession = {
94 authenticated: true,
95 did: "did:plc:abc123",
96 handle: "alice.bsky.social",
97 };
98 const authApp = new Hono().get("/", (c) =>
99 c.html(<BaseLayout auth={auth}>content</BaseLayout>)
100 );
101 const res = await authApp.request("/");
102 const html = await res.text();
103 expect(html).toContain("alice.bsky.social");
104 expect(html).toContain("Log out");
105 expect(html).not.toContain('href="/login"');
106 });
107
108 it("renders logout as a form POST (not a link)", async () => {
109 const auth: WebSession = {
110 authenticated: true,
111 did: "did:plc:abc123",
112 handle: "alice.bsky.social",
113 };
114 const authApp = new Hono().get("/", (c) =>
115 c.html(<BaseLayout auth={auth}>content</BaseLayout>)
116 );
117 const res = await authApp.request("/");
118 const html = await res.text();
119 // Logout must be a form POST for CSRF protection, not a plain link
120 expect(html).toContain('action="/logout"');
121 expect(html).toContain('method="post"');
122 expect(html).toContain("Log out");
123 });
124 });
125
126 describe("accessibility", () => {
127 it("renders skip-to-content link before the site header", async () => {
128 const res = await app.request("/");
129 const html = await res.text();
130 expect(html).toContain('class="skip-link"');
131 expect(html).toContain('href="#main-content"');
132 expect(html).toContain("Skip to main content");
133 // Skip link must come before header in DOM order
134 const skipLinkPos = html.indexOf("skip-link");
135 const headerPos = html.indexOf("site-header");
136 expect(skipLinkPos).toBeLessThan(headerPos);
137 });
138
139 it("renders main element with id for skip link target", async () => {
140 const res = await app.request("/");
141 const html = await res.text();
142 expect(html).toContain('id="main-content"');
143 });
144
145 it("desktop nav has aria-label for Main navigation", async () => {
146 const res = await app.request("/");
147 const html = await res.text();
148 expect(html).toContain('aria-label="Main navigation"');
149 });
150
151 it("mobile nav has distinct aria-label", async () => {
152 const res = await app.request("/");
153 const html = await res.text();
154 expect(html).toContain('aria-label="Mobile navigation"');
155 });
156 });
157
158 describe("favicon", () => {
159 it("includes favicon link in head", async () => {
160 const res = await app.request("/");
161 const html = await res.text();
162 expect(html).toContain('rel="icon"');
163 expect(html).toContain("favicon.svg");
164 });
165 });
166
167 describe("mobile navigation", () => {
168 it("renders details/summary hamburger menu for mobile", async () => {
169 const res = await app.request("/");
170 const html = await res.text();
171 expect(html).toContain("mobile-nav");
172 expect(html).toContain("mobile-nav__toggle");
173 });
174
175 it("renders desktop nav separately from mobile nav", async () => {
176 const res = await app.request("/");
177 const html = await res.text();
178 expect(html).toContain("desktop-nav");
179 });
180
181 it("hamburger has aria-label for accessibility", async () => {
182 const res = await app.request("/");
183 const html = await res.text();
184 expect(html).toContain('aria-label="Menu"');
185 });
186
187 it("mobile nav contains login link when not authenticated", async () => {
188 const res = await app.request("/");
189 const html = await res.text();
190 // Both mobile and desktop nav should have "Log in"
191 const loginMatches = html.match(/Log in/g);
192 expect(loginMatches!.length).toBe(2);
193 });
194
195 it("mobile nav contains auth state when logged in", async () => {
196 const auth: WebSession = {
197 authenticated: true,
198 did: "did:plc:abc123",
199 handle: "alice.bsky.social",
200 };
201 const authApp = new Hono().get("/", (c) =>
202 c.html(<BaseLayout auth={auth}>content</BaseLayout>)
203 );
204 const res = await authApp.request("/");
205 const html = await res.text();
206 // Both mobile and desktop nav should have "Log out"
207 const logoutMatches = html.match(/Log out/g);
208 expect(logoutMatches!.length).toBe(2);
209 });
210 });
211});