ATB-53: Theme Resolution and Server-Side Token Injection — Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Wire the resolved theme into every server-rendered HTML response — fetching the active theme from the AppView, applying the waterfall (user pref → color scheme → forum default → hardcoded fallback), and injecting the winning tokens as a <style>:root { ... }</style> block in BaseLayout.
Architecture: A Hono middleware runs before all page routes, calls resolveTheme(), and sets the result on a typed Hono context variable. BaseLayout accepts resolvedTheme as a required prop and renders tokens, font URLs, and optional CSS overrides dynamically. The AppView's GET /api/themes/:rkey gains a cid field to enable the CID integrity check.
Tech Stack: Hono (middleware + typed Variables), Vitest (vi.stubGlobal for fetch mocking, vi.mock for module mocking), TypeScript, existing tokensToCss() utility, existing neobrutal-light.json preset.
Context#
All dependencies are done: theme lexicons (ATB-51), CSS token system (ATB-52), theme read API (ATB-55), theme write API (ATB-57), theme list page (ATB-58), token editor (ATB-59).
What already exists:
apps/web/src/lib/theme.ts—tokensToCss(tokens)utilityapps/web/src/styles/presets/neobrutal-light.jsonandneobrutal-dark.jsonapps/appview/src/routes/themes.ts—GET /api/themes/:rkey(missingcidin response)apps/appview/src/routes/__tests__/themes.test.ts— existing tests (nocidassertion yet)apps/web/src/layouts/base.tsx— currently hardcodes neobrutal-light as module-level constantapps/web/src/layouts/__tests__/base.test.tsx— existing tests pass<BaseLayout>withoutresolvedTheme
Fetch-mocking pattern (from session.test.ts):
const mockFetch = vi.fn();
beforeEach(() => { vi.stubGlobal("fetch", mockFetch); });
afterEach(() => { vi.unstubAllGlobals(); mockFetch.mockReset(); });
Test commands:
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/middleware/__tests__/theme.test.ts
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/layouts/__tests__/base.test.tsx
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run
PATH=.devenv/profile/bin:$PATH pnpm test
Task 1: AppView — Add cid to GET /api/themes/:rkey response#
Files:
- Modify:
apps/appview/src/routes/themes.ts(serializeThemeFullfunction) - Modify:
apps/appview/src/routes/__tests__/themes.test.ts(addcidassertion)
Step 1: Add failing assertion to existing test#
In apps/appview/src/routes/__tests__/themes.test.ts, find the test "returns full theme data including tokens, cssOverrides, and fontUrls" and add one line:
// After: expect(body.indexedAt).toBeDefined();
expect(body.cid).toBe("bafyfull");
Step 2: Run test to verify it fails#
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts
Expected: FAIL — expect(received).toBe(expected) with received: undefined.
Step 3: Add cid to serializeThemeFull#
In apps/appview/src/routes/themes.ts, update serializeThemeFull:
function serializeThemeFull(theme: ThemeRow) {
return {
id: serializeBigInt(theme.id),
uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`,
cid: theme.cid, // ← add this line
name: theme.name,
colorScheme: theme.colorScheme,
tokens: theme.tokens,
cssOverrides: theme.cssOverrides ?? null,
fontUrls: (theme.fontUrls as string[] | null) ?? null,
createdAt: serializeDate(theme.createdAt),
indexedAt: serializeDate(theme.indexedAt),
};
}
Step 4: Run tests to verify they pass#
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts
Expected: all PASS.
Step 5: Commit#
git add apps/appview/src/routes/themes.ts apps/appview/src/routes/__tests__/themes.test.ts
git commit -m "feat(appview): include cid in GET /api/themes/:rkey response (ATB-53)"
Task 2: Web — Core types, helpers, and FALLBACK_THEME#
Files:
- Create:
apps/web/src/lib/theme-resolution.ts - Create:
apps/web/src/lib/__tests__/theme-resolution.test.ts
Step 1: Write failing tests for detectColorScheme and parseRkeyFromUri#
Create apps/web/src/lib/__tests__/theme-resolution.test.ts:
import { describe, it, expect } from "vitest";
import {
detectColorScheme,
parseRkeyFromUri,
FALLBACK_THEME,
} from "../theme-resolution.js";
describe("detectColorScheme", () => {
it("returns 'light' by default when no cookie or hint", () => {
expect(detectColorScheme(undefined, undefined)).toBe("light");
});
it("reads atbb-color-scheme=dark from cookie", () => {
expect(detectColorScheme("atbb-color-scheme=dark; other=1", undefined)).toBe("dark");
});
it("reads atbb-color-scheme=light from cookie", () => {
expect(detectColorScheme("atbb-color-scheme=light", undefined)).toBe("light");
});
it("prefers cookie over client hint", () => {
expect(detectColorScheme("atbb-color-scheme=light", "dark")).toBe("light");
});
it("falls back to client hint when no cookie", () => {
expect(detectColorScheme(undefined, "dark")).toBe("dark");
});
it("ignores unrecognized hint values and returns 'light'", () => {
expect(detectColorScheme(undefined, "no-preference")).toBe("light");
});
});
describe("parseRkeyFromUri", () => {
it("extracts rkey from valid AT URI", () => {
expect(
parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme/3lblthemeabc")
).toBe("3lblthemeabc");
});
it("returns null for URI with no rkey segment", () => {
expect(parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme")).toBeNull();
});
it("returns null for malformed URI", () => {
expect(parseRkeyFromUri("not-a-uri")).toBeNull();
});
it("returns null for empty string", () => {
expect(parseRkeyFromUri("")).toBeNull();
});
});
describe("FALLBACK_THEME", () => {
it("uses neobrutal-light tokens", () => {
expect(FALLBACK_THEME.tokens["color-bg"]).toBe("#f5f0e8");
expect(FALLBACK_THEME.tokens["color-primary"]).toBe("#ff5c00");
});
it("has light colorScheme", () => {
expect(FALLBACK_THEME.colorScheme).toBe("light");
});
it("includes Google Fonts URL for Space Grotesk", () => {
expect(FALLBACK_THEME.fontUrls).toEqual(
expect.arrayContaining([expect.stringContaining("Space+Grotesk")])
);
});
it("has null cssOverrides", () => {
expect(FALLBACK_THEME.cssOverrides).toBeNull();
});
});
Step 2: Run to verify they fail#
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts
Expected: FAIL — module not found.
Step 3: Create apps/web/src/lib/theme-resolution.ts with types, helpers, and FALLBACK_THEME#
import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" };
export type ResolvedTheme = {
tokens: Record<string, string>;
cssOverrides: string | null;
fontUrls: string[] | null;
colorScheme: "light" | "dark";
};
/** Hono app environment type — used by middleware and all route factories. */
export type WebAppEnv = {
Variables: { theme: ResolvedTheme };
};
/** Hardcoded fallback used when theme policy is missing or resolution fails. */
export const FALLBACK_THEME: ResolvedTheme = {
tokens: neobrutalLight as Record<string, string>,
cssOverrides: null,
fontUrls: [
"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap",
],
colorScheme: "light",
};
/**
* Detects the user's preferred color scheme.
* Priority: atbb-color-scheme cookie → Sec-CH-Prefers-Color-Scheme hint → "light".
*/
export function detectColorScheme(
cookieHeader: string | undefined,
hint: string | undefined
): "light" | "dark" {
const match = cookieHeader?.match(/atbb-color-scheme=(light|dark)/);
if (match) return match[1] as "light" | "dark";
if (hint === "dark") return "dark";
return "light";
}
/**
* Extracts the rkey segment from an AT URI.
* Example: "at://did:plc:abc/space.atbb.forum.theme/rkey123" → "rkey123"
*/
export function parseRkeyFromUri(atUri: string): string | null {
// Format: at://<did>/<collection>/<rkey>
// Split gives: ["at:", "", "<did>", "<collection>", "<rkey>"]
const parts = atUri.split("/");
if (parts.length < 5) return null;
return parts[4] ?? null;
}
Step 4: Run tests to verify they pass#
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts
Expected: all PASS.
Step 5: Commit#
git add apps/web/src/lib/theme-resolution.ts apps/web/src/lib/__tests__/theme-resolution.test.ts
git commit -m "feat(web): add ResolvedTheme types, FALLBACK_THEME, and color scheme helpers (ATB-53)"
Task 3: Web — resolveTheme() waterfall#
Files:
- Modify:
apps/web/src/lib/theme-resolution.ts - Modify:
apps/web/src/lib/__tests__/theme-resolution.test.ts
Step 1: Write failing tests for all waterfall branches#
Add a new describe("resolveTheme", ...) block to the end of apps/web/src/lib/__tests__/theme-resolution.test.ts:
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
detectColorScheme,
parseRkeyFromUri,
resolveTheme,
FALLBACK_THEME,
} from "../theme-resolution.js";
import { logger } from "../logger.js";
vi.mock("../logger.js", () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
},
}));
// ... (keep existing describe blocks above, add this new one:)
describe("resolveTheme", () => {
const mockFetch = vi.fn();
const APPVIEW = "http://localhost:3001";
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
vi.mocked(logger.warn).mockClear();
vi.mocked(logger.error).mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
mockFetch.mockReset();
});
function policyResponse(overrides: object = {}) {
return {
ok: true,
json: () =>
Promise.resolve({
defaultLightThemeUri:
"at://did:plc:forum/space.atbb.forum.theme/3lbllight",
defaultDarkThemeUri:
"at://did:plc:forum/space.atbb.forum.theme/3lbldark",
allowUserChoice: true,
availableThemes: [
{
uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight",
cid: "bafylight",
},
{
uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark",
cid: "bafydark",
},
],
...overrides,
}),
};
}
function themeResponse(colorScheme: "light" | "dark", cid: string) {
return {
ok: true,
json: () =>
Promise.resolve({
cid,
tokens: { "color-bg": colorScheme === "light" ? "#fff" : "#111" },
cssOverrides: null,
fontUrls: null,
colorScheme,
}),
};
}
it("returns FALLBACK_THEME with detected colorScheme when policy fetch fails (non-ok)", async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
const result = await resolveTheme(APPVIEW, undefined, undefined);
expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
expect(result.colorScheme).toBe("light");
});
it("returns FALLBACK_THEME with dark colorScheme when policy fails and dark cookie set", async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined);
expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
expect(result.colorScheme).toBe("dark");
});
it("returns FALLBACK_THEME when policy has no defaultLightThemeUri", async () => {
mockFetch.mockResolvedValueOnce(
policyResponse({ defaultLightThemeUri: null })
);
const result = await resolveTheme(APPVIEW, undefined, undefined);
expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
});
it("returns FALLBACK_THEME when theme fetch fails", async () => {
mockFetch
.mockResolvedValueOnce(policyResponse())
.mockResolvedValueOnce({ ok: false, status: 404 });
const result = await resolveTheme(APPVIEW, undefined, undefined);
expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
});
it("returns FALLBACK_THEME and logs warning on CID mismatch", async () => {
mockFetch
.mockResolvedValueOnce(policyResponse())
.mockResolvedValueOnce(themeResponse("light", "WRONG_CID"));
const result = await resolveTheme(APPVIEW, undefined, undefined);
expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("CID mismatch"),
expect.objectContaining({ expectedCid: "bafylight", actualCid: "WRONG_CID" })
);
});
it("resolves the light theme on happy path (no cookie)", async () => {
mockFetch
.mockResolvedValueOnce(policyResponse())
.mockResolvedValueOnce(themeResponse("light", "bafylight"));
const result = await resolveTheme(APPVIEW, undefined, undefined);
expect(result.tokens["color-bg"]).toBe("#fff");
expect(result.colorScheme).toBe("light");
expect(result.cssOverrides).toBeNull();
expect(result.fontUrls).toBeNull();
});
it("resolves the dark theme when atbb-color-scheme=dark cookie is set", async () => {
mockFetch
.mockResolvedValueOnce(policyResponse())
.mockResolvedValueOnce(themeResponse("dark", "bafydark"));
const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined);
expect(result.tokens["color-bg"]).toBe("#111");
expect(result.colorScheme).toBe("dark");
// Verify the dark theme URI was fetched (3lbldark, not 3lbllight)
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("3lbldark")
);
});
it("resolves dark theme from Sec-CH-Prefers-Color-Scheme hint when no cookie", async () => {
mockFetch
.mockResolvedValueOnce(policyResponse())
.mockResolvedValueOnce(themeResponse("dark", "bafydark"));
const result = await resolveTheme(APPVIEW, undefined, "dark");
expect(result.colorScheme).toBe("dark");
});
it("returns FALLBACK_THEME and logs error on network exception", async () => {
mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
const result = await resolveTheme(APPVIEW, undefined, undefined);
expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining("Theme resolution failed"),
expect.objectContaining({ operation: "resolveTheme" })
);
});
it("passes cssOverrides and fontUrls through from theme response", async () => {
mockFetch
.mockResolvedValueOnce(policyResponse())
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
cid: "bafylight",
tokens: { "color-bg": "#fff" },
cssOverrides: ".btn { font-weight: 700; }",
fontUrls: ["https://fonts.example.com/font.css"],
colorScheme: "light",
}),
});
const result = await resolveTheme(APPVIEW, undefined, undefined);
expect(result.cssOverrides).toBe(".btn { font-weight: 700; }");
expect(result.fontUrls).toEqual(["https://fonts.example.com/font.css"]);
});
});
Note: Also add the vi.mock("../logger.js", ...) block and update the import line at the top of the test file.
Step 2: Run to verify they fail#
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts
Expected: FAIL — resolveTheme is not exported.
Step 3: Add resolveTheme to apps/web/src/lib/theme-resolution.ts#
Add after the existing helpers. Also add the internal type interfaces and the logger import:
import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" };
import { logger } from "./logger.js";
// ... (existing exports: ResolvedTheme, WebAppEnv, FALLBACK_THEME, detectColorScheme, parseRkeyFromUri)
interface ThemePolicyResponse {
defaultLightThemeUri: string | null;
defaultDarkThemeUri: string | null;
allowUserChoice: boolean;
availableThemes: Array<{ uri: string; cid: string }>;
}
interface ThemeResponse {
cid: string;
tokens: Record<string, unknown>;
cssOverrides: string | null;
fontUrls: string[] | null;
}
/**
* Resolves which theme to render for a request using the waterfall:
* 1. User preference — not yet implemented (TODO: Theme Phase 4)
* 2. Color scheme default — atbb-color-scheme cookie or Sec-CH hint
* 3. Forum default — fetched from GET /api/theme-policy
* 4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light)
*
* Never throws — always returns a usable theme.
*/
export async function resolveTheme(
appviewUrl: string,
cookieHeader: string | undefined,
colorSchemeHint: string | undefined
): Promise<ResolvedTheme> {
const colorScheme = detectColorScheme(cookieHeader, colorSchemeHint);
// Step 1: User preference
// TODO: implement when space.atbb.membership.preferredTheme is added (Theme Phase 4)
// Steps 2-3: Forum default via theme policy
try {
const policyRes = await fetch(`${appviewUrl}/api/theme-policy`);
if (!policyRes.ok) {
return { ...FALLBACK_THEME, colorScheme };
}
const policy = (await policyRes.json()) as ThemePolicyResponse;
const defaultUri =
colorScheme === "dark"
? policy.defaultDarkThemeUri
: policy.defaultLightThemeUri;
if (!defaultUri) {
return { ...FALLBACK_THEME, colorScheme };
}
const rkey = parseRkeyFromUri(defaultUri);
if (!rkey) {
return { ...FALLBACK_THEME, colorScheme };
}
const expectedCid =
policy.availableThemes.find((t) => t.uri === defaultUri)?.cid ?? null;
const themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`);
if (!themeRes.ok) {
return { ...FALLBACK_THEME, colorScheme };
}
const theme = (await themeRes.json()) as ThemeResponse;
if (expectedCid && theme.cid !== expectedCid) {
logger.warn("Theme CID mismatch — using hardcoded fallback", {
operation: "resolveTheme",
expectedCid,
actualCid: theme.cid,
themeUri: defaultUri,
});
return { ...FALLBACK_THEME, colorScheme };
}
return {
tokens: theme.tokens as Record<string, string>,
cssOverrides: theme.cssOverrides ?? null,
fontUrls: theme.fontUrls ?? null,
colorScheme,
};
} catch (error) {
// Intentionally don't re-throw: a broken theme system should serve the
// fallback and log the error, rather than crash every page request.
logger.error("Theme resolution failed — using hardcoded fallback", {
operation: "resolveTheme",
error: error instanceof Error ? error.message : String(error),
});
return { ...FALLBACK_THEME, colorScheme };
}
}
Step 4: Run tests to verify they pass#
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts
Expected: all PASS.
Step 5: Commit#
git add apps/web/src/lib/theme-resolution.ts apps/web/src/lib/__tests__/theme-resolution.test.ts
git commit -m "feat(web): implement resolveTheme waterfall with CID integrity check (ATB-53)"
Task 4: Web — Theme middleware#
Files:
- Create:
apps/web/src/middleware/theme.ts - Create:
apps/web/src/middleware/__tests__/theme.test.ts
Step 1: Write failing test#
Create apps/web/src/middleware/__tests__/theme.test.ts:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import { createThemeMiddleware } from "../theme.js";
import { FALLBACK_THEME } from "../../lib/theme-resolution.js";
import type { WebAppEnv } from "../../lib/theme-resolution.js";
vi.mock("../../lib/theme-resolution.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../lib/theme-resolution.js")>();
return {
...actual,
resolveTheme: vi.fn().mockResolvedValue(actual.FALLBACK_THEME),
};
});
const { resolveTheme } = await import("../../lib/theme-resolution.js");
describe("createThemeMiddleware", () => {
const APPVIEW = "http://localhost:3001";
beforeEach(() => {
vi.mocked(resolveTheme).mockClear();
});
function makeApp() {
return new Hono<WebAppEnv>()
.use("*", createThemeMiddleware(APPVIEW))
.get("/test", (c) => {
const theme = c.get("theme");
return c.json({ colorScheme: theme.colorScheme });
});
}
it("sets resolved theme on context so handlers can access it", async () => {
const app = makeApp();
const res = await app.request("/test");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.colorScheme).toBe("light");
});
it("forwards cookie header to resolveTheme", async () => {
const app = makeApp();
await app.request("/test", {
headers: { cookie: "atbb-color-scheme=dark" },
});
expect(resolveTheme).toHaveBeenCalledWith(
APPVIEW,
"atbb-color-scheme=dark",
undefined
);
});
it("forwards Sec-CH-Prefers-Color-Scheme header to resolveTheme", async () => {
const app = makeApp();
await app.request("/test", {
headers: { "sec-ch-prefers-color-scheme": "dark" },
});
expect(resolveTheme).toHaveBeenCalledWith(
APPVIEW,
undefined,
"dark"
);
});
it("calls next() so the route handler executes", async () => {
const app = makeApp();
const res = await app.request("/test");
expect(res.status).toBe(200); // not 404 — next() was called
});
});
Step 2: Run to verify it fails#
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/middleware/__tests__/theme.test.ts
Expected: FAIL — module not found.
Step 3: Create apps/web/src/middleware/theme.ts#
import type { MiddlewareHandler } from "hono";
import { resolveTheme } from "../lib/theme-resolution.js";
import type { WebAppEnv } from "../lib/theme-resolution.js";
export function createThemeMiddleware(
appviewUrl: string
): MiddlewareHandler<WebAppEnv> {
return async (c, next) => {
const resolvedTheme = await resolveTheme(
appviewUrl,
c.req.header("cookie"),
c.req.header("Sec-CH-Prefers-Color-Scheme")
);
c.set("theme", resolvedTheme);
await next();
};
}
Step 4: Run tests to verify they pass#
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/middleware/__tests__/theme.test.ts
Expected: all PASS.
Step 5: Commit#
git add apps/web/src/middleware/theme.ts apps/web/src/middleware/__tests__/theme.test.ts
git commit -m "feat(web): add theme resolution middleware (ATB-53)"
Task 5: Web — Wire middleware into routes/index.ts#
Files:
- Modify:
apps/web/src/routes/index.ts
Step 1: Update routes/index.ts#
import { Hono } from "hono";
import { loadConfig } from "../lib/config.js";
import { createThemeMiddleware } from "../middleware/theme.js";
import type { WebAppEnv } from "../lib/theme-resolution.js";
import { createHomeRoutes } from "./home.js";
import { createBoardsRoutes } from "./boards.js";
import { createTopicsRoutes } from "./topics.js";
import { createLoginRoutes } from "./login.js";
import { createNewTopicRoutes } from "./new-topic.js";
import { createAuthRoutes } from "./auth.js";
import { createModActionRoute } from "./mod.js";
import { createAdminRoutes } from "./admin.js";
import { createNotFoundRoute } from "./not-found.js";
const config = loadConfig();
export const webRoutes = new Hono<WebAppEnv>()
.use("*", createThemeMiddleware(config.appviewUrl))
.route("/", createHomeRoutes(config.appviewUrl))
.route("/", createBoardsRoutes(config.appviewUrl))
.route("/", createTopicsRoutes(config.appviewUrl))
.route("/", createLoginRoutes(config.appviewUrl))
.route("/", createNewTopicRoutes(config.appviewUrl))
.route("/", createAuthRoutes(config.appviewUrl))
.route("/", createModActionRoute(config.appviewUrl))
.route("/", createAdminRoutes(config.appviewUrl))
.route("/", createNotFoundRoute(config.appviewUrl));
Step 2: Run TypeScript check#
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec tsc --noEmit
Expected: TypeScript errors about new Hono() in route factories not having WebAppEnv. These will be fixed in Task 7.
Step 3: Commit (even with TS errors — the plan fixes them in Task 7)#
git add apps/web/src/routes/index.ts
git commit -m "feat(web): apply theme middleware to webRoutes (ATB-53)"
Task 6: Web — Update BaseLayout#
Files:
- Modify:
apps/web/src/layouts/base.tsx - Modify:
apps/web/src/layouts/__tests__/base.test.tsx
Step 1: Update tests#
Replace the top of apps/web/src/layouts/__tests__/base.test.tsx to import FALLBACK_THEME and pass it everywhere <BaseLayout> is used:
import { describe, it, expect } from "vitest";
import { Hono } from "hono";
import { BaseLayout } from "../base.js";
import type { WebSession } from "../../lib/session.js";
import { FALLBACK_THEME } from "../../lib/theme-resolution.js";
// Default test app — all tests use FALLBACK_THEME unless they need specific theme values
const app = new Hono().get("/", (c) =>
c.html(
<BaseLayout title="Test Page" resolvedTheme={FALLBACK_THEME}>
Page content
</BaseLayout>
)
);
Then update every other new Hono().get(...) call in the file to also pass resolvedTheme={FALLBACK_THEME}.
Add these new tests at the end of the file:
describe("theme injection", () => {
it("renders Accept-CH meta tag for Sec-CH-Prefers-Color-Scheme client hint", async () => {
const res = await app.request("/");
const html = await res.text();
expect(html).toContain('http-equiv="Accept-CH"');
expect(html).toContain("Sec-CH-Prefers-Color-Scheme");
});
it("injects resolved tokens as :root CSS custom properties", async () => {
const res = await app.request("/");
const html = await res.text();
expect(html).toContain(":root {");
expect(html).toContain("--color-bg:");
expect(html).toContain("--color-primary:");
});
it("renders fontUrls from resolvedTheme as stylesheet links", async () => {
const theme = {
...FALLBACK_THEME,
fontUrls: ["https://fonts.example.com/custom.css"],
};
const fontApp = new Hono().get("/", (c) =>
c.html(<BaseLayout resolvedTheme={theme}>content</BaseLayout>)
);
const res = await fontApp.request("/");
const html = await res.text();
expect(html).toContain('href="https://fonts.example.com/custom.css"');
expect(html).toContain('rel="preconnect"');
});
it("renders cssOverrides as an additional style block when present", async () => {
const theme = {
...FALLBACK_THEME,
cssOverrides: ".btn { font-weight: 900; }",
};
const overrideApp = new Hono().get("/", (c) =>
c.html(<BaseLayout resolvedTheme={theme}>content</BaseLayout>)
);
const res = await overrideApp.request("/");
const html = await res.text();
expect(html).toContain(".btn { font-weight: 900; }");
});
it("does not render a cssOverrides style block when null", async () => {
const res = await app.request("/");
const html = await res.text();
// Only the :root tokens block should be present, not a second style block
const styleBlocks = html.match(/<style>/g) ?? [];
expect(styleBlocks.length).toBe(1);
});
});
Step 2: Run tests to verify they fail#
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/layouts/__tests__/base.test.tsx
Expected: FAIL — resolvedTheme prop not accepted by BaseLayout.
Step 3: Rewrite apps/web/src/layouts/base.tsx#
import type { FC, PropsWithChildren } from "hono/jsx";
import { tokensToCss } from "../lib/theme.js";
import type { WebSession } from "../lib/session.js";
import type { ResolvedTheme } from "../lib/theme-resolution.js";
const NavContent: FC<{ auth?: WebSession }> = ({ auth }) => (
<>
{auth?.authenticated ? (
<>
<span class="site-header__handle">{auth.handle}</span>
<form action="/logout" method="post" class="site-header__logout-form">
<button type="submit" class="site-header__logout-btn">
Log out
</button>
</form>
</>
) : (
<a href="/login" class="site-header__login-link">
Log in
</a>
)}
</>
);
export const BaseLayout: FC<
PropsWithChildren<{
title?: string;
auth?: WebSession;
resolvedTheme: ResolvedTheme;
}>
> = (props) => {
const { auth, resolvedTheme } = props;
const rootCss = `:root { ${tokensToCss(resolvedTheme.tokens)} }`;
const hasFontUrls =
resolvedTheme.fontUrls !== null && resolvedTheme.fontUrls.length > 0;
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" />
<title>{props.title ?? "atBB Forum"}</title>
<style>{rootCss}</style>
{hasFontUrls && (
<link rel="preconnect" href="https://fonts.googleapis.com" />
)}
{resolvedTheme.fontUrls?.map((url) => (
<link rel="stylesheet" href={url} />
))}
<link rel="stylesheet" href="/static/css/reset.css" />
<link rel="stylesheet" href="/static/css/theme.css" />
{resolvedTheme.cssOverrides && (
<style>{resolvedTheme.cssOverrides}</style>
)}
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<script src="https://unpkg.com/htmx.org@2.0.4" defer />
</head>
<body>
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<header class="site-header">
<div class="site-header__inner">
<a href="/" class="site-header__title">
atBB Forum
</a>
<nav class="desktop-nav" aria-label="Main navigation">
<NavContent auth={auth} />
</nav>
<details class="mobile-nav">
<summary class="mobile-nav__toggle" aria-label="Menu">
☰
</summary>
<nav class="mobile-nav__menu" aria-label="Mobile navigation">
<NavContent auth={auth} />
</nav>
</details>
</div>
</header>
<main id="main-content" class="content-container">
{props.children}
</main>
<footer class="site-footer">
<p>Powered by atBB on the ATmosphere</p>
</footer>
</body>
</html>
);
};
Step 4: Run tests to verify they pass#
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/layouts/__tests__/base.test.tsx
Expected: all PASS.
Step 5: Commit#
git add apps/web/src/layouts/base.tsx apps/web/src/layouts/__tests__/base.test.tsx
git commit -m "feat(web): BaseLayout accepts resolvedTheme prop, adds Accept-CH meta and dynamic font URLs (ATB-53)"
Task 7: Web — Update all HTML-rendering route factories#
Files (all in apps/web/src/routes/):
- Modify:
home.tsx,boards.tsx,topics.tsx,login.tsx,new-topic.tsx,admin.tsx,not-found.tsx— update Hono type + read theme + pass to BaseLayout - Modify:
auth.ts,mod.ts— update Hono type only (no BaseLayout calls)
Step 1: Apply the pattern to each HTML-rendering route#
For each file (home.tsx, boards.tsx, topics.tsx, login.tsx, new-topic.tsx, admin.tsx, not-found.tsx):
a) Add import at the top:
import type { WebAppEnv } from "../lib/theme-resolution.js";
b) Change the Hono instance:
// Before:
return new Hono().get(...)
// After:
return new Hono<WebAppEnv>().get(...)
c) At the top of the route handler function body, add:
const theme = c.get("theme");
d) Add resolvedTheme={theme} to every <BaseLayout> call in that handler (including error-path renders — theme is always available since it's set before any async calls).
For auth.ts and mod.ts (no BaseLayout, just update the Hono type):
import type { WebAppEnv } from "../lib/theme-resolution.js";
// Change: new Hono() → new Hono<WebAppEnv>()
Step 2: Run all web tests#
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run
Expected: all PASS. TypeScript --noEmit should now pass too:
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec tsc --noEmit
Step 3: Commit#
git add apps/web/src/routes/
git commit -m "feat(web): update all route factories to use WebAppEnv and pass resolvedTheme to BaseLayout (ATB-53)"
Task 8: Final verification and Bruno collection update#
Step 1: Run full test suite#
PATH=.devenv/profile/bin:$PATH pnpm test
Expected: all PASS across all packages.
Step 2: Run lint fix#
PATH=.devenv/profile/bin:$PATH pnpm turbo lint:fix
Step 3: Check Bruno collections#
Open bruno/ directory. The ATB-53 changes are server-side rendering only — no new API endpoints are added (the AppView GET /api/themes/:rkey now returns cid, which should be documented). Update the GET /api/themes/:rkey Bruno request to show cid in the example response body if there is one.
Step 4: Update Linear and plan doc#
Mark ATB-53 as Done in Linear with a comment summarising the implementation. Move docs/plans/2026-03-04-atb53-theme-resolution-design.md and this file to docs/plans/complete/.
mkdir -p docs/plans/complete
git mv docs/plans/2026-03-04-atb53-theme-resolution-design.md docs/plans/complete/
git mv docs/plans/2026-03-04-atb53-theme-resolution-implementation.md docs/plans/complete/
git add docs/plans/complete/
git commit -m "docs: move ATB-53 plan docs to complete/ (ATB-53)"