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 ResolvedTheme types, FALLBACK_THEME, and color scheme helpers (ATB-53)

+122
+73
apps/web/src/lib/__tests__/theme-resolution.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + detectColorScheme, 4 + parseRkeyFromUri, 5 + FALLBACK_THEME, 6 + } from "../theme-resolution.js"; 7 + 8 + describe("detectColorScheme", () => { 9 + it("returns 'light' by default when no cookie or hint", () => { 10 + expect(detectColorScheme(undefined, undefined)).toBe("light"); 11 + }); 12 + 13 + it("reads atbb-color-scheme=dark from cookie", () => { 14 + expect(detectColorScheme("atbb-color-scheme=dark; other=1", undefined)).toBe("dark"); 15 + }); 16 + 17 + it("reads atbb-color-scheme=light from cookie", () => { 18 + expect(detectColorScheme("atbb-color-scheme=light", undefined)).toBe("light"); 19 + }); 20 + 21 + it("prefers cookie over client hint", () => { 22 + expect(detectColorScheme("atbb-color-scheme=light", "dark")).toBe("light"); 23 + }); 24 + 25 + it("falls back to client hint when no cookie", () => { 26 + expect(detectColorScheme(undefined, "dark")).toBe("dark"); 27 + }); 28 + 29 + it("ignores unrecognized hint values and returns 'light'", () => { 30 + expect(detectColorScheme(undefined, "no-preference")).toBe("light"); 31 + }); 32 + }); 33 + 34 + describe("parseRkeyFromUri", () => { 35 + it("extracts rkey from valid AT URI", () => { 36 + expect( 37 + parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme/3lblthemeabc") 38 + ).toBe("3lblthemeabc"); 39 + }); 40 + 41 + it("returns null for URI with no rkey segment", () => { 42 + expect(parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme")).toBeNull(); 43 + }); 44 + 45 + it("returns null for malformed URI", () => { 46 + expect(parseRkeyFromUri("not-a-uri")).toBeNull(); 47 + }); 48 + 49 + it("returns null for empty string", () => { 50 + expect(parseRkeyFromUri("")).toBeNull(); 51 + }); 52 + }); 53 + 54 + describe("FALLBACK_THEME", () => { 55 + it("uses neobrutal-light tokens", () => { 56 + expect(FALLBACK_THEME.tokens["color-bg"]).toBe("#f5f0e8"); 57 + expect(FALLBACK_THEME.tokens["color-primary"]).toBe("#ff5c00"); 58 + }); 59 + 60 + it("has light colorScheme", () => { 61 + expect(FALLBACK_THEME.colorScheme).toBe("light"); 62 + }); 63 + 64 + it("includes Google Fonts URL for Space Grotesk", () => { 65 + expect(FALLBACK_THEME.fontUrls).toEqual( 66 + expect.arrayContaining([expect.stringContaining("Space+Grotesk")]) 67 + ); 68 + }); 69 + 70 + it("has null cssOverrides", () => { 71 + expect(FALLBACK_THEME.cssOverrides).toBeNull(); 72 + }); 73 + });
+49
apps/web/src/lib/theme-resolution.ts
··· 1 + import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; 2 + 3 + export type ResolvedTheme = { 4 + tokens: Record<string, string>; 5 + cssOverrides: string | null; 6 + fontUrls: string[] | null; 7 + colorScheme: "light" | "dark"; 8 + }; 9 + 10 + /** Hono app environment type — used by middleware and all route factories. */ 11 + export type WebAppEnv = { 12 + Variables: { theme: ResolvedTheme }; 13 + }; 14 + 15 + /** Hardcoded fallback used when theme policy is missing or resolution fails. */ 16 + export const FALLBACK_THEME: ResolvedTheme = { 17 + tokens: neobrutalLight as Record<string, string>, 18 + cssOverrides: null, 19 + fontUrls: [ 20 + "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap", 21 + ], 22 + colorScheme: "light", 23 + }; 24 + 25 + /** 26 + * Detects the user's preferred color scheme. 27 + * Priority: atbb-color-scheme cookie → Sec-CH-Prefers-Color-Scheme hint → "light". 28 + */ 29 + export function detectColorScheme( 30 + cookieHeader: string | undefined, 31 + hint: string | undefined 32 + ): "light" | "dark" { 33 + const match = cookieHeader?.match(/atbb-color-scheme=(light|dark)/); 34 + if (match) return match[1] as "light" | "dark"; 35 + if (hint === "dark") return "dark"; 36 + return "light"; 37 + } 38 + 39 + /** 40 + * Extracts the rkey segment from an AT URI. 41 + * Example: "at://did:plc:abc/space.atbb.forum.theme/rkey123" → "rkey123" 42 + */ 43 + export function parseRkeyFromUri(atUri: string): string | null { 44 + // Format: at://<did>/<collection>/<rkey> 45 + // Split gives: ["at:", "", "<did>", "<collection>", "<rkey>"] 46 + const parts = atUri.split("/"); 47 + if (parts.length < 5) return null; 48 + return parts[4] ?? null; 49 + }