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

fix: theme toggle not switching between light and dark mode

Two bugs prevented the light/dark toggle from working:

1. toggleColorScheme() used `m&&m[1]==='light'` which evaluates to null
(falsy) when no cookie exists yet, causing the first toggle click to
set cookie to 'light' instead of 'dark' — a no-op since light is the
default. Fixed by extracting current scheme before toggling.

2. FALLBACK_THEME always used neobrutal-light tokens regardless of color
scheme. When no dark theme is configured in the policy, toggling to
dark changed the icon but kept light-colored tokens. Added
fallbackForScheme() that returns neobrutal-dark tokens for dark mode.

https://claude.ai/code/session_01CnyPWgayLMmPZ2Ritq2Lcj

Claude 21ba5258 55e49381

+44 -13
+1 -1
apps/web/src/layouts/base.tsx
··· 130 130 </footer> 131 131 <script 132 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();}`, 133 + __html: `function toggleColorScheme(){var m=document.cookie.match(/(?:^|;\\s*)atbb-color-scheme=(light|dark)/);var current=m?m[1]:'light';var next=current==='light'?'dark':'light';document.cookie='atbb-color-scheme='+next+';path=/;max-age=31536000;SameSite=Lax';location.reload();}`, 134 134 }} 135 135 /> 136 136 </body>
+17 -2
apps/web/src/lib/__tests__/theme-resolution.test.ts
··· 3 3 detectColorScheme, 4 4 parseRkeyFromUri, 5 5 FALLBACK_THEME, 6 + fallbackForScheme, 6 7 resolveTheme, 7 8 } from "../theme-resolution.js"; 8 9 import { ThemeCache } from "../theme-cache.js"; ··· 85 86 }); 86 87 }); 87 88 89 + describe("fallbackForScheme", () => { 90 + it("returns light tokens for light color scheme", () => { 91 + const result = fallbackForScheme("light"); 92 + expect(result.tokens["color-bg"]).toBe("#f5f0e8"); 93 + expect(result.colorScheme).toBe("light"); 94 + }); 95 + 96 + it("returns dark tokens for dark color scheme", () => { 97 + const result = fallbackForScheme("dark"); 98 + expect(result.tokens["color-bg"]).toBe("#1a1a1a"); 99 + expect(result.colorScheme).toBe("dark"); 100 + }); 101 + }); 102 + 88 103 describe("resolveTheme", () => { 89 104 const mockFetch = vi.fn(); 90 105 const mockLogger = vi.mocked(logger); ··· 145 160 ); 146 161 }); 147 162 148 - it("returns FALLBACK_THEME with dark colorScheme when policy fails and dark cookie set", async () => { 163 + it("returns dark fallback tokens when policy fails and dark cookie set", async () => { 149 164 mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); 150 165 const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); 151 - expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 166 + expect(result.tokens).toEqual(fallbackForScheme("dark").tokens); 152 167 expect(result.colorScheme).toBe("dark"); 153 168 expect(mockLogger.warn).toHaveBeenCalledWith( 154 169 expect.stringContaining("non-ok status"),
+26 -10
apps/web/src/lib/theme-resolution.ts
··· 1 1 import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; 2 + import neobrutalDark from "../styles/presets/neobrutal-dark.json" with { type: "json" }; 2 3 import { isProgrammingError } from "./errors.js"; 3 4 import { logger } from "./logger.js"; 4 5 import { ThemeCache, type CachedPolicy, type CachedTheme } from "./theme-cache.js"; ··· 25 26 colorScheme: "light", 26 27 } as const) as ResolvedTheme; 27 28 29 + /** Returns a fallback theme with the correct tokens for the given color scheme. */ 30 + export function fallbackForScheme(colorScheme: "light" | "dark"): ResolvedTheme { 31 + return { 32 + tokens: 33 + colorScheme === "dark" 34 + ? (neobrutalDark as Record<string, string>) 35 + : (neobrutalLight as Record<string, string>), 36 + cssOverrides: null, 37 + fontUrls: [ 38 + "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap", 39 + ], 40 + colorScheme, 41 + }; 42 + } 43 + 28 44 /** 29 45 * Detects the user's preferred color scheme. 30 46 * Priority: atbb-color-scheme cookie → Sec-CH-Prefers-Color-Scheme hint → "light". ··· 57 73 * 1. User preference — not yet implemented (TODO: Theme Phase 4) 58 74 * 2. Color scheme default — atbb-color-scheme cookie or Sec-CH hint 59 75 * 3. Forum default — fetched from GET /api/theme-policy (cached in memory) 60 - * 4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light) 76 + * 4. Hardcoded fallback — neobrutal-light or neobrutal-dark per color scheme 61 77 * 62 78 * Pass a ThemeCache instance to enable in-memory TTL caching of policy and 63 79 * theme data. The cache is checked before each network request and populated ··· 87 103 status: policyRes.status, 88 104 url: `${appviewUrl}/api/theme-policy`, 89 105 }); 90 - return { ...FALLBACK_THEME, colorScheme }; 106 + return fallbackForScheme(colorScheme); 91 107 } 92 108 } catch (error) { 93 109 if (isProgrammingError(error)) throw error; ··· 95 111 operation: "resolveTheme", 96 112 error: error instanceof Error ? error.message : String(error), 97 113 }); 98 - return { ...FALLBACK_THEME, colorScheme }; 114 + return fallbackForScheme(colorScheme); 99 115 } 100 116 101 117 try { ··· 107 123 operation: "resolveTheme", 108 124 url: `${appviewUrl}/api/theme-policy`, 109 125 }); 110 - return { ...FALLBACK_THEME, colorScheme }; 126 + return fallbackForScheme(colorScheme); 111 127 } 112 128 } 113 129 114 130 // ── Step 2: Extract default theme URI and rkey ───────────────────────────── 115 131 const defaultUri = 116 132 colorScheme === "dark" ? policy.defaultDarkThemeUri : policy.defaultLightThemeUri; 117 - if (!defaultUri) return { ...FALLBACK_THEME, colorScheme }; 133 + if (!defaultUri) return fallbackForScheme(colorScheme); 118 134 119 135 const rkey = parseRkeyFromUri(defaultUri); 120 - if (!rkey || !/^[a-z0-9-]+$/i.test(rkey)) return { ...FALLBACK_THEME, colorScheme }; 136 + if (!rkey || !/^[a-z0-9-]+$/i.test(rkey)) return fallbackForScheme(colorScheme); 121 137 122 138 const matchingTheme = policy.availableThemes.find((t) => t.uri === defaultUri); 123 139 if (!matchingTheme) { ··· 168 184 rkey, 169 185 themeUri: defaultUri, 170 186 }); 171 - return { ...FALLBACK_THEME, colorScheme }; 187 + return fallbackForScheme(colorScheme); 172 188 } 173 189 } catch (error) { 174 190 if (isProgrammingError(error)) throw error; ··· 177 193 rkey, 178 194 error: error instanceof Error ? error.message : String(error), 179 195 }); 180 - return { ...FALLBACK_THEME, colorScheme }; 196 + return fallbackForScheme(colorScheme); 181 197 } 182 198 183 199 // ── Step 5: Parse theme JSON ─────────────────────────────────────────────── ··· 190 206 rkey, 191 207 themeUri: defaultUri, 192 208 }); 193 - return { ...FALLBACK_THEME, colorScheme }; 209 + return fallbackForScheme(colorScheme); 194 210 } 195 211 196 212 // ── Step 6: CID integrity check ──────────────────────────────────────────── ··· 201 217 actualCid: theme.cid, 202 218 themeUri: defaultUri, 203 219 }); 204 - return { ...FALLBACK_THEME, colorScheme }; 220 + return fallbackForScheme(colorScheme); 205 221 } 206 222 207 223 // Populate cache only after successful validation