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

Configure Feed

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

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.tstokensToCss(tokens) utility
  • apps/web/src/styles/presets/neobrutal-light.json and neobrutal-dark.json
  • apps/appview/src/routes/themes.tsGET /api/themes/:rkey (missing cid in response)
  • apps/appview/src/routes/__tests__/themes.test.ts — existing tests (no cid assertion yet)
  • apps/web/src/layouts/base.tsx — currently hardcodes neobrutal-light as module-level constant
  • apps/web/src/layouts/__tests__/base.test.tsx — existing tests pass <BaseLayout> without resolvedTheme

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 (serializeThemeFull function)
  • Modify: apps/appview/src/routes/__tests__/themes.test.ts (add cid assertion)

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">
                &#9776;
              </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)"