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): add light/dark mode toggle to site header (ATB-54) (#94)

Adds a cookie-based color scheme toggle button to the site header (both
desktop and mobile navs). Clicking it flips the atbb-color-scheme cookie
between light/dark and reloads the page so the server re-renders with the
correct preset tokens resolved by ATB-53's theme middleware.

- NavContent now accepts colorScheme and renders a toggle button with a
contextual aria-label ("Switch to dark mode" / "Switch to light mode")
- toggleColorScheme() vanilla JS sets cookie (path=/, max-age=1yr,
SameSite=Lax) and calls location.reload()
- .color-scheme-toggle CSS class follows neobrutal button aesthetics
- 5 new tests cover button presence, aria-label for both modes, dual-nav
rendering, onclick wiring, and cookie attribute correctness

authored by

Malpercio and committed by
GitHub
f1a0adf1 61a234b5

+113 -20
+29
apps/web/public/static/css/theme.css
··· 178 178 font-weight: var(--font-weight-bold); 179 179 } 180 180 181 + .color-scheme-toggle { 182 + cursor: pointer; 183 + display: inline-flex; 184 + align-items: center; 185 + justify-content: center; 186 + font-family: var(--font-body); 187 + font-size: var(--font-size-base); 188 + line-height: 1; 189 + border: var(--border-width) solid var(--color-border); 190 + border-radius: var(--button-radius); 191 + padding: var(--space-xs) var(--space-sm); 192 + box-shadow: var(--button-shadow); 193 + background-color: var(--color-surface); 194 + color: var(--color-text); 195 + min-width: 36px; 196 + min-height: 36px; 197 + transition: transform 0.1s ease, box-shadow 0.1s ease; 198 + } 199 + 200 + .color-scheme-toggle:hover { 201 + transform: translate(var(--btn-press-hover), var(--btn-press-hover)); 202 + box-shadow: var(--btn-press-hover) var(--btn-press-hover) 0 var(--color-shadow); 203 + } 204 + 205 + .color-scheme-toggle:active { 206 + transform: translate(var(--btn-press-active), var(--btn-press-active)); 207 + box-shadow: none; 208 + } 209 + 181 210 .content-container { 182 211 max-width: var(--content-width); 183 212 margin: 0 auto;
+43
apps/web/src/layouts/__tests__/base.test.tsx
··· 245 245 }); 246 246 }); 247 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 + 248 291 describe("favicon", () => { 249 292 it("includes favicon link in head", async () => { 250 293 const res = await app.request("/");
+41 -20
apps/web/src/layouts/base.tsx
··· 4 4 import type { ResolvedTheme } from "../lib/theme-resolution.js"; 5 5 import type { WebSession } from "../lib/session.js"; 6 6 7 - const NavContent: FC<{ auth?: WebSession }> = ({ auth }) => ( 8 - <> 9 - {auth?.authenticated ? ( 10 - <> 11 - <span class="site-header__handle">{auth.handle}</span> 12 - <form action="/logout" method="post" class="site-header__logout-form"> 13 - <button type="submit" class="site-header__logout-btn"> 14 - Log out 15 - </button> 16 - </form> 17 - </> 18 - ) : ( 19 - <a href="/login" class="site-header__login-link"> 20 - Log in 21 - </a> 22 - )} 23 - </> 24 - ); 7 + const NavContent: FC<{ auth?: WebSession; colorScheme: "light" | "dark" }> = ({ 8 + auth, 9 + colorScheme, 10 + }) => { 11 + const toggleLabel = 12 + colorScheme === "light" ? "Switch to dark mode" : "Switch to light mode"; 13 + const toggleIcon = colorScheme === "light" ? "\u263D" : "\u2600"; 14 + return ( 15 + <> 16 + <button 17 + class="color-scheme-toggle" 18 + onclick="toggleColorScheme()" 19 + aria-label={toggleLabel} 20 + title={toggleLabel} 21 + > 22 + {toggleIcon} 23 + </button> 24 + {auth?.authenticated ? ( 25 + <> 26 + <span class="site-header__handle">{auth.handle}</span> 27 + <form action="/logout" method="post" class="site-header__logout-form"> 28 + <button type="submit" class="site-header__logout-btn"> 29 + Log out 30 + </button> 31 + </form> 32 + </> 33 + ) : ( 34 + <a href="/login" class="site-header__login-link"> 35 + Log in 36 + </a> 37 + )} 38 + </> 39 + ); 40 + }; 25 41 26 42 export const BaseLayout: FC< 27 43 PropsWithChildren<{ ··· 94 110 atBB Forum 95 111 </a> 96 112 <nav class="desktop-nav" aria-label="Main navigation"> 97 - <NavContent auth={auth} /> 113 + <NavContent auth={auth} colorScheme={resolvedTheme.colorScheme} /> 98 114 </nav> 99 115 <details class="mobile-nav"> 100 116 <summary class="mobile-nav__toggle" aria-label="Menu"> 101 117 &#9776; 102 118 </summary> 103 119 <nav class="mobile-nav__menu" aria-label="Mobile navigation"> 104 - <NavContent auth={auth} /> 120 + <NavContent auth={auth} colorScheme={resolvedTheme.colorScheme} /> 105 121 </nav> 106 122 </details> 107 123 </div> ··· 112 128 <footer class="site-footer"> 113 129 <p>Powered by atBB on the ATmosphere</p> 114 130 </footer> 131 + <script 132 + dangerouslySetInnerHTML={{ 133 + __html: `function toggleColorScheme(){var m=document.cookie.match(/(?:^|;\\s*)atbb-color-scheme=(light|dark)/);var next=m&&m[1]==='light'?'dark':'light';document.cookie='atbb-color-scheme='+next+';path=/;max-age=31536000;SameSite=Lax';location.reload();}`, 134 + }} 135 + /> 115 136 </body> 116 137 </html> 117 138 );