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.

docs: add user theme preferences design plan

Completed brainstorming session. Design includes:
- Cookie-based preference storage (atbb-light-theme, atbb-dark-theme)
- PRG form with HTMX live color swatch preview
- User preference as first step in theme resolution waterfall
- 5 implementation phases with full acceptance criteria

+184
+184
docs/design-plans/2026-03-20-user-theme-preferences.md
··· 1 + # User Theme Preferences Design 2 + 3 + ## Summary 4 + 5 + This design adds a `/settings` page where authenticated users can select their preferred light and dark themes from the set the forum administrator has made available. Preferences are stored as two browser cookies (`atbb-light-theme`, `atbb-dark-theme`) and applied on every subsequent page load without requiring the user to reconfigure anything. A live color swatch preview, powered by HTMX, lets users see a theme's palette immediately when they change the dropdown selection — before they commit by saving the form. 6 + 7 + The implementation slots into the existing theme system. The forum already resolves a theme per request through a "waterfall": it checks the color-scheme cookie, looks up the forum's default theme from a cached policy, and falls back to a hardcoded preset if everything else fails. This design adds a new first step to that waterfall: if the user has a saved preference cookie and the referenced theme is still present in the current policy, it is used instead of the forum default. The settings page reuses the same `ThemeCache`, the same POST-Redirect-GET form pattern as the admin theme editor, and the same raw `Set-Cookie` header approach as the authentication routes. 8 + 9 + ## Definition of Done 10 + 11 + - Users can open a `/settings` page and choose which light theme and which dark theme to use from the forum's available themes 12 + - Preferences are stored as two cookies (`atbb-light-theme`, `atbb-dark-theme`) and applied on every page load 13 + - Selecting a theme in the settings UI shows a live color preview before the user saves 14 + - The theme resolution waterfall respects user preferences over forum defaults 15 + - If `allowUserChoice` is false, the settings page informs the user and does not offer selection 16 + - Server validates cookie values against the current theme policy before applying 17 + 18 + ## Acceptance Criteria 19 + 20 + ### user-theme-preferences.AC1: Settings page is accessible 21 + - **user-theme-preferences.AC1.1 Success:** Authenticated users see a Settings link in the site nav 22 + - **user-theme-preferences.AC1.2 Success:** `/settings` renders with light-theme and dark-theme selects when `allowUserChoice: true` 23 + - **user-theme-preferences.AC1.3 Success:** Changing the light-theme select swaps in a color swatch preview 24 + - **user-theme-preferences.AC1.4 Success:** Changing the dark-theme select swaps in a color swatch preview 25 + - **user-theme-preferences.AC1.5 Failure:** Unauthenticated users visiting `/settings` are redirected to `/login` 26 + 27 + ### user-theme-preferences.AC2: Preferences can be saved 28 + - **user-theme-preferences.AC2.1 Success:** Submitting the form with valid URIs sets `atbb-light-theme` and `atbb-dark-theme` cookies 29 + - **user-theme-preferences.AC2.2 Success:** After saving, the page shows a "Preferences saved" confirmation banner 30 + - **user-theme-preferences.AC2.3 Success:** On revisiting `/settings`, the saved themes are pre-selected in the dropdowns 31 + 32 + ### user-theme-preferences.AC3: Preferences are applied on page load 33 + - **user-theme-preferences.AC3.1 Success:** When `atbb-light-theme` cookie is set and still in the policy, that theme renders in light mode 34 + - **user-theme-preferences.AC3.2 Success:** When `atbb-dark-theme` cookie is set and still in the policy, that theme renders in dark mode 35 + - **user-theme-preferences.AC3.3 Edge:** When a cookie URI is no longer in the theme policy, the forum default is used silently (no error page) 36 + 37 + ### user-theme-preferences.AC4: Server validates inputs 38 + - **user-theme-preferences.AC4.1 Failure:** POST with a URI absent from `availableThemes` is rejected (redirects with `?error=invalid-theme`) 39 + - **user-theme-preferences.AC4.2 Failure:** POST when `allowUserChoice: false` is rejected server-side (even without the form) 40 + - **user-theme-preferences.AC4.3 Failure:** POST with missing or malformed body fields is rejected (`?error=invalid`) 41 + - **user-theme-preferences.AC4.4 Failure:** POST when policy fetch fails returns safe error — no cookies are set 42 + 43 + ### user-theme-preferences.AC5: `allowUserChoice: false` is respected 44 + - **user-theme-preferences.AC5.1 Success:** Settings page shows an informational banner instead of selects when `allowUserChoice: false` 45 + - **user-theme-preferences.AC5.2 Success:** Theme resolution ignores user preference cookies when `allowUserChoice: false` and uses forum default 46 + 47 + ### user-theme-preferences.AC6: Preview endpoint 48 + - **user-theme-preferences.AC6.1 Success:** `GET /settings/preview?theme=<valid-uri>` returns an HTML fragment with swatches and theme name 49 + - **user-theme-preferences.AC6.2 Edge:** `GET /settings/preview?theme=<unknown-uri>` returns an empty `<div id="theme-preview">` fragment without crashing 50 + 51 + ## Glossary 52 + 53 + - **allowUserChoice**: A field on the forum's theme policy that determines whether users are permitted to override the forum's default theme. When `false`, the settings page shows an informational message and the POST endpoint rejects submissions server-side. 54 + - **AT URI**: A stable identifier for a record in the AT Protocol, in the form `at://<did>/<collection>/<rkey>`. Themes are referenced by AT URI throughout this design rather than by a name or database ID. 55 + - **availableThemes**: The list of theme AT URIs (with optional CIDs) that the forum administrator has permitted for use. User-submitted theme preferences are validated against this list. 56 + - **BaseLayout / NavContent**: The shared Hono JSX layout component that wraps every page. `NavContent` is the portion that renders the navigation bar, where the Settings link is added. 57 + - **CID (Content Identifier)**: A content-addressed hash of an AT Protocol record's data. A theme policy may reference a specific CID to pin a theme to a version. 58 + - **colorScheme**: Whether a given theme is intended for light or dark display environments. The forum selects which theme to apply based on the user's detected color scheme and this field. 59 + - **defense-in-depth**: A security principle applied here by validating `allowUserChoice` on the server side even when the UI already hides the form, so a crafted HTTP request cannot bypass an administrator's configuration. 60 + - **fail-closed**: A security posture where any error (e.g., a failed policy fetch) results in a safe default being used rather than an unsafe action being permitted. 61 + - **Hono**: The TypeScript web framework used for both the AppView and the web frontend. Routes, middleware, and JSX templates all use Hono's API. 62 + - **HTML fragment**: A partial HTML response (not a full page) returned by an endpoint. HTMX swaps the fragment into the existing page DOM without a full navigation. The `/settings/preview` endpoint returns a fragment of color swatches. 63 + - **HTMX**: A library that adds declarative Ajax and partial-page updates to HTML via attributes like `hx-get`, `hx-trigger`, and `hx-target`. Used here to fire the preview endpoint when a `<select>` changes. 64 + - **POST-Redirect-GET (PRG)**: A web pattern where a form submission (POST) is handled server-side, then the server responds with a redirect (302) to a GET URL. Prevents duplicate submissions on browser refresh; used here for saving theme preferences. 65 + - **theme policy**: The forum-level configuration record (fetched from the AppView at `/api/theme-policy`) that specifies which themes are available, which are defaults for light/dark mode, and whether users may choose their own. 66 + - **theme resolution waterfall**: The ordered sequence of fallback steps the server uses to decide which theme to render for a request. This design adds user preference cookies as the first (highest-priority) step before the forum default. 67 + - **ThemeCache**: The in-memory, TTL-based cache in `apps/web/src/lib/theme-cache.ts` that stores fetched theme policy and individual theme token data for up to 5 minutes, reducing repeated AppView requests. 68 + - **TTL (time-to-live)**: The duration an item is kept in a cache before being considered stale. The `ThemeCache` uses a 5-minute TTL. 69 + 70 + ## Architecture 71 + 72 + PRG (POST-Redirect-GET) for saving, with an HTMX live-preview panel that fires on `<select>` change before the user commits. 73 + 74 + A new route file (`apps/web/src/routes/settings.tsx`) registers three endpoints: 75 + 76 + - `GET /settings` — fetches the theme policy, partitions available themes by `colorScheme`, reads existing preference cookies to pre-select values, and renders the settings page inside `BaseLayout` 77 + - `GET /settings/preview?theme=<uri>` — HTMX endpoint; fetches the theme by URI and returns an HTML fragment of color swatches (used to preview a theme before saving) 78 + - `POST /settings/appearance` — validates `lightThemeUri` and `darkThemeUri` against the current policy's `availableThemes`, sets `atbb-light-theme` and `atbb-dark-theme` cookies, redirects 302 to `/settings?saved=1` 79 + 80 + The theme resolution waterfall in `apps/web/src/lib/theme-resolution.ts` gains a new first lookup step: read the user preference cookie for the active color scheme and validate the stored URI is still present in `availableThemes`. If valid, use it; if stale or missing, fall through to the existing forum-default and preset-fallback steps. 81 + 82 + `BaseLayout`'s `NavContent` gains a Settings link visible only to authenticated users. 83 + 84 + ## Existing Patterns 85 + 86 + The closest codebase analogue is the admin theme policy form in `apps/web/src/routes/admin-themes.tsx`. That form uses plain `<form method="post">` with `c.req.parseBody()` on the POST handler and a 302 redirect on success — the same PRG pattern this design follows. 87 + 88 + HTMX live preview is already used in `admin-themes.tsx` (`POST /admin/themes/:rkey/preview`) for the admin theme editor. The user-facing preview here uses `hx-get` on `<select> change` instead of `hx-post`, but the fragment-return pattern is the same. 89 + 90 + Cookie handling follows `apps/web/src/routes/auth.ts` (lines 47–50): raw `Set-Cookie` headers on a `new Response()`, not Hono's `c.cookie()` helper. 91 + 92 + Theme policy and individual theme data are fetched through `apps/web/src/lib/theme-cache.ts`, which provides a 5-minute in-memory TTL cache. This design uses the same cache for both the settings page render and the POST validation step. 93 + 94 + ## Implementation Phases 95 + 96 + <!-- START_PHASE_1 --> 97 + ### Phase 1: Theme Resolution — User Preference Waterfall 98 + 99 + **Goal:** Make the theme system read and apply user preference cookies before falling back to forum defaults. 100 + 101 + **Components:** 102 + - `apps/web/src/lib/theme-resolution.ts` — new `resolveUserThemePreference()` function and updated `resolveTheme()` call site to slot it in at step 1 of the waterfall 103 + - Unit tests for `resolveUserThemePreference()` covering: valid cookie URI in policy (uses it), stale URI not in policy (returns null), missing cookie (returns null), `allowUserChoice: false` (returns null) 104 + 105 + **Dependencies:** None — this is a pure function change with no UI surface. 106 + 107 + **Done when:** Tests pass for all cases; `pnpm test` passes; theme resolution uses user cookie when valid. 108 + 109 + **Covers:** user-theme-preferences.AC3.1, user-theme-preferences.AC3.2, user-theme-preferences.AC3.3, user-theme-preferences.AC5.1, user-theme-preferences.AC5.2 110 + <!-- END_PHASE_1 --> 111 + 112 + <!-- START_PHASE_2 --> 113 + ### Phase 2: Settings Route — GET and POST Handlers 114 + 115 + **Goal:** Implement the settings page route with form rendering and preference saving. 116 + 117 + **Components:** 118 + - `apps/web/src/routes/settings.tsx` — new route file with `GET /settings` and `POST /settings/appearance` handlers 119 + - `GET /settings`: reads theme policy from cache, partitions themes by `colorScheme`, reads preference cookies, renders settings page inside `BaseLayout`; redirects unauthenticated users to `/login`; shows informational banner when `allowUserChoice: false`; shows `?saved=1` and `?error=*` banners 120 + - `POST /settings/appearance`: parses form body, fetches fresh policy, validates URIs in `availableThemes`, sets raw `Set-Cookie` headers, redirects 302 121 + - Route registered in `apps/web/src/index.ts` (or equivalent main router) 122 + - Integration tests covering: page renders for authenticated user, redirects unauthenticated to login, saves valid preferences, rejects URIs not in policy, handles `allowUserChoice: false`, handles policy fetch failure 123 + 124 + **Dependencies:** Phase 1 (theme resolution updated to use cookies) 125 + 126 + **Done when:** All integration tests pass; `pnpm test` passes; preferences round-trip correctly. 127 + 128 + **Covers:** user-theme-preferences.AC1.1, user-theme-preferences.AC1.2, user-theme-preferences.AC2.1, user-theme-preferences.AC2.2, user-theme-preferences.AC2.3, user-theme-preferences.AC4.1, user-theme-preferences.AC4.2, user-theme-preferences.AC4.3, user-theme-preferences.AC4.4, user-theme-preferences.AC6.1, user-theme-preferences.AC6.2 129 + <!-- END_PHASE_2 --> 130 + 131 + <!-- START_PHASE_3 --> 132 + ### Phase 3: HTMX Live Preview Endpoint 133 + 134 + **Goal:** Add the color swatch preview fragment returned when the user changes a `<select>`. 135 + 136 + **Components:** 137 + - `GET /settings/preview?theme=<uri>` handler in `apps/web/src/routes/settings.tsx` — fetches theme from cache, extracts key tokens (`color-bg`, `color-surface`, `color-primary`, `color-text`, `color-border`), returns an HTML fragment with swatches and theme name; returns empty `<div id="theme-preview">` on unknown URI or fetch failure 138 + - `<select>` elements in the settings page carry `hx-get="/settings/preview?theme={value}"`, `hx-trigger="change"`, `hx-target="#theme-preview"`, `hx-swap="outerHTML"` 139 + - Tests: preview returns swatch fragment for valid URI; returns empty div for unknown URI; select attributes are present in rendered page HTML 140 + 141 + **Dependencies:** Phase 2 (settings page and route file exist) 142 + 143 + **Done when:** Tests pass; selecting a theme in the UI swaps in a swatch preview; `pnpm test` passes. 144 + 145 + **Covers:** user-theme-preferences.AC1.3, user-theme-preferences.AC1.4 146 + <!-- END_PHASE_3 --> 147 + 148 + <!-- START_PHASE_4 --> 149 + ### Phase 4: Navigation Link 150 + 151 + **Goal:** Expose the settings page via the site navigation. 152 + 153 + **Components:** 154 + - `apps/web/src/layouts/base.tsx` — add Settings link inside `NavContent`, visible only when `auth?.authenticated` is true 155 + - Test: authenticated nav renders settings link; unauthenticated nav does not 156 + 157 + **Dependencies:** Phase 2 (settings route exists and handles the request) 158 + 159 + **Done when:** Tests pass; authenticated users see Settings link; unauthenticated users do not; `pnpm test` passes. 160 + 161 + **Covers:** user-theme-preferences.AC1.1 (discoverability), user-theme-preferences.AC2.1 162 + <!-- END_PHASE_4 --> 163 + 164 + <!-- START_PHASE_5 --> 165 + ### Phase 5: Bruno API Collection 166 + 167 + **Goal:** Document the three new endpoints in the Bruno collection. 168 + 169 + **Components:** 170 + - `bruno/` collection entries for `GET /settings`, `GET /settings/preview`, `POST /settings/appearance` 171 + - Each entry documents request shape, expected response, and all HTTP status codes the handler can return 172 + 173 + **Dependencies:** Phases 2–3 (endpoints implemented) 174 + 175 + **Done when:** Bruno files committed alongside route implementation; all status codes documented. 176 + <!-- END_PHASE_5 --> 177 + 178 + ## Additional Considerations 179 + 180 + **Cookie validation security:** The `POST /settings/appearance` handler fetches a fresh copy of the theme policy (not the cached version used for rendering) to validate URIs. A stale cache hit could allow a recently-removed theme to be set as a preference. Fresh fetch on POST prevents this. 181 + 182 + **`allowUserChoice` enforcement:** The POST handler checks `allowUserChoice` server-side even though the UI hides the form when it is false. This follows defense-in-depth — a crafted POST must not bypass admin intent. 183 + 184 + **Stale preference cookies:** If an admin removes a theme from the policy after a user has saved it as their preference, `resolveUserThemePreference()` will return `null` on the next page load and fall through to the forum default. No error is surfaced — the user simply gets the forum default until they revisit settings and re-pick.