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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: theme toggle not switching between light and dark mode (#98)

* 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

* test: add regression coverage for toggle logic and dark-scheme fallback paths

Add pinning test for the corrected toggleColorScheme script to prevent
silent reversion to the null-evaluating m&&m[1]==='light' pattern.

Add dark-scheme network exception test for resolveTheme to verify
fallbackForScheme() returns dark tokens on all fallback paths, not just
the !policyRes.ok path that was previously the only dark-scheme test.

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by

Malpercio
Claude
and committed by
GitHub
0e46c369 55e49381

+67 -13
+13
apps/web/src/layouts/__tests__/base.test.tsx
··· 286 286 expect(html).toContain("SameSite=Lax"); 287 287 expect(html).toContain("path=/"); 288 288 }); 289 + 290 + it("toggleColorScheme script defaults to 'light' when no cookie present (first toggle must produce 'dark')", async () => { 291 + // Regression: the old script used `m&&m[1]==='light'` which evaluates to `null` 292 + // (not `false`) when no cookie exists, causing the first toggle to always produce 293 + // 'light' instead of 'dark'. The fix introduces a `current` variable that defaults 294 + // to 'light', ensuring `next` is always the opposite of the current scheme. 295 + const res = await app.request("/"); 296 + const html = await res.text(); 297 + // Verify the corrected pattern is present: `current` defaults to 'light' when no cookie 298 + expect(html).toContain("var current=m?m[1]:'light'"); 299 + // Verify `next` is derived from `current`, not from the raw regex match 300 + expect(html).toContain("current==='light'?'dark':'light'"); 301 + }); 289 302 }); 290 303 291 304 describe("favicon", () => {
+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>
+27 -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"; ··· 82 83 83 84 it("has null cssOverrides", () => { 84 85 expect(FALLBACK_THEME.cssOverrides).toBeNull(); 86 + }); 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"); 85 100 }); 86 101 }); 87 102 ··· 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"), ··· 233 248 expect.stringContaining("Theme policy fetch failed"), 234 249 expect.objectContaining({ operation: "resolveTheme" }) 235 250 ); 251 + }); 252 + 253 + it("returns dark fallback tokens when network exception occurs with dark cookie", async () => { 254 + // Regression: fallbackForScheme() must return dark tokens when the detected scheme is dark. 255 + // Previously, all fallback paths returned FALLBACK_THEME (light tokens) regardless of scheme. 256 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 257 + const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); 258 + expect(result.tokens).toEqual(fallbackForScheme("dark").tokens); 259 + expect(result.colorScheme).toBe("dark"); 260 + expect(result.tokens).not.toEqual(FALLBACK_THEME.tokens); 236 261 }); 237 262 238 263 it("re-throws programming errors (TypeError) rather than swallowing them", async () => {
+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