diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md index b64a98efe..e8e5b1cca 100644 --- a/docs/SETUP_GUIDE.md +++ b/docs/SETUP_GUIDE.md @@ -234,4 +234,5 @@ Control plane cannot reach Modal (or Modal is not properly configured/deployed). - Linear integration usage: [docs/integrations/LINEAR.md](./integrations/LINEAR.md) - Debugging and observability: [docs/DEBUGGING_PLAYBOOK.md](./DEBUGGING_PLAYBOOK.md) - OpenAI model setup: [docs/OPENAI_MODELS.md](./OPENAI_MODELS.md) +- Theming and design tokens: [docs/THEMING.md](./THEMING.md) - Contribution workflow: [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/docs/THEMING.md b/docs/THEMING.md new file mode 100644 index 000000000..daa848402 --- /dev/null +++ b/docs/THEMING.md @@ -0,0 +1,204 @@ +# Theming Guide + +The Open-Inspect web app uses a token-based theming system. Every color comes from a CSS custom +property defined in [`packages/web/src/app/globals.css`](../packages/web/src/app/globals.css), and +components reference those tokens through Tailwind semantic classes (`bg-background`, +`text-foreground`, `border-border`, …) — never raw hex values. That indirection is what lets the +"App theme" picker in **Settings → Appearance** swap the entire UI between light and dark instantly, +and what makes adding a new themed palette later a contained change. + +## Architecture + +Three layers cooperate: + +1. **CSS custom properties.** Each visual concept (background, foreground, accent, border, etc.) is + a `--token`. Built-in light/dark values live in `globals.css` (`:root`, `.dark`, plus a + `@media (prefers-color-scheme: dark)` block as a no-JS fallback so the page doesn't flash white + before hydration on dark systems). Branded themes live one-per-file in + [`packages/web/src/app/themes/`](../packages/web/src/app/themes/) and override the same tokens + under their own class selector (e.g., `.blue { ... }` in `themes/blue.css`). +2. **Tailwind semantic utilities** (`bg-background`, `text-muted-foreground`, …). They are wired to + the CSS tokens in the Tailwind config, so writing `className="bg-card text-foreground"` follows + the active theme automatically. +3. **`next-themes`** in + [`packages/web/src/app/providers.tsx`](../packages/web/src/app/providers.tsx). It manages the + `dark` class on ``, persists the user's choice to localStorage, hydrates on mount without a + flash, and syncs across tabs. The Appearance picker is a thin UI on top of `useTheme()`. + +## Token catalog + +The current tokens (defined in `globals.css`; branded theme files override the subset they need): + +| Token | Purpose | +| ------------------------------------------------------------------------------------------ | ----------------------------------------- | +| `--background`, `--foreground` | Page background and primary text | +| `--card`, `--card-foreground` | Card / panel surface and its text | +| `--popover`, `--popover-foreground` | Dropdown / popover surface and its text | +| `--primary`, `--primary-foreground` | Primary button (inverted) | +| `--secondary`, `--secondary-foreground` | Secondary surface and de-emphasized text | +| `--accent`, `--accent-foreground`, `--accent-muted` | Brand accent + foreground + tinted bg | +| `--muted`, `--muted-foreground` | Subtle background tint and secondary text | +| `--destructive`, `--destructive-foreground`, `--destructive-muted`, `--destructive-border` | Error / destructive states | +| `--success`, `--success-muted` | Success states | +| `--warning`, `--warning-foreground`, `--warning-muted` | Warning states | +| `--info`, `--info-foreground`, `--info-muted` | Info states | +| `--border`, `--border-muted` | Borders and dividers | +| `--input` | Form field background | +| `--ring` | Focus outline | +| `--overlay` | Modal / drawer scrim | +| `--radius` | Default border radius | + +If a new visual concept doesn't fit an existing token, **add a token** (with light + dark values) +before introducing a hard-coded color. Tokens are cheap; hard-coded colors leak. + +## Switching themes at runtime + +The Appearance picker calls `setTheme()` from `next-themes`. That: + +- Sets the active theme's `id` as a class on `` (so `.blue { ... }` rules win when the user + picks "Blue", `.dark` for "Dark", and so on). +- Persists the choice to `localStorage` (`theme` key). +- Survives reloads and syncs across browser tabs. + +Components that need to react to the active theme can read it from `useTheme()` — +[`syntax-highlight-theme.tsx`](../packages/web/src/components/syntax-highlight-theme.tsx) reads both +`theme` and `resolvedTheme` to pick the right hljs stylesheet, and +[`ui/sonner.tsx`](../packages/web/src/components/ui/sonner.tsx) reads the active theme so toasts +match. + +## The theme registry + +All available themes live in +[`packages/web/src/lib/app-themes.ts`](../packages/web/src/lib/app-themes.ts): + +```ts +export const APP_THEMES: AppTheme[] = [ + { id: "light", label: "Default", colorScheme: "light" }, + { id: "dark", label: "Dark", colorScheme: "dark" }, + { id: "system", label: "System", colorScheme: "system" }, + { id: "blue", label: "Blue", colorScheme: "light" }, +]; +``` + +`id` is what gets put on `` and what users persist; `label` shows in the picker; `colorScheme` +tells the rest of the app whether to treat this theme as light, dark, or auto-following the OS — +primarily so syntax highlighting picks the right hljs stylesheet for a custom palette. + +## Setting a default theme on deploy + +Useful when you want a company-branded theme to be the first thing every new visitor sees, without +forcing it on them — users can still switch in Settings → Appearance and their choice persists. + +Set the `app_default_theme` tfvar in +[`terraform/environments/production/terraform.tfvars`](../terraform/environments/production/terraform.tfvars.example): + +```hcl +app_default_theme = "blue" +``` + +The variable is validated against the known theme ids in `variables.tf` (and again at runtime — an +unknown id silently falls back to `"system"` so a typo never ships a broken UI). The value becomes +`NEXT_PUBLIC_APP_DEFAULT_THEME` at build time, which `site-config.ts` reads and feeds to +next-themes' `defaultTheme` prop. Because `NEXT_PUBLIC_*` vars are inlined into the client bundle, +**you must redeploy the web app for a change to take effect** (rebuild + push for Cloudflare, new +Vercel deployment for Vercel). + +Default is `"system"` — the historical behavior. + +## Adding a new branded theme + +To ship your own brand (e.g., "Acme Purple"): + +1. **Create a CSS file at + [`packages/web/src/app/themes/.css`](../packages/web/src/app/themes/).** One file per theme, + keyed by the id you'll register — e.g., `themes/acme-purple.css`. Use a class selector that + matches the id: + + ```css + .acme-purple { + --accent: #7b3ff2; + --ring: #7b3ff2; + --background: #1a0d2e; + --foreground: #f4f0ff; + /* leave the rest inheriting from :root */ + } + ``` + + You only need to override the tokens that change; everything else inherits from `:root`. Each + named palette is light-only or dark-only — `next-themes` is configured with `attribute="class"`, + which puts a single theme class on `` at a time, so selectors like `.acme-purple.dark` + never match. If you want both a light and dark variant of a brand, register them as two separate + themes (e.g., `acme-purple` and `acme-purple-dark`) with their own files. + +2. **Import the file from [`app/layout.tsx`](../packages/web/src/app/layout.tsx)** after the + existing `./globals.css` import: + + ```ts + import "./globals.css"; + import "./themes/acme-purple.css"; + ``` + + Order matters — branded themes must come after `globals.css` so their rules override the `:root` + defaults whenever the matching class is active on ``. + +3. **Register it in [`app-themes.ts`](../packages/web/src/lib/app-themes.ts).** Add an entry to + `APP_THEMES`: + + ```ts + { id: "acme-purple", label: "Acme Purple", colorScheme: "dark" }, + ``` + + Pick `colorScheme: "light"` if your palette has light backgrounds, `"dark"` for dark — this is + what syntax highlighting reads when deciding whether to pair the palette with a light or dark + hljs stylesheet. + +4. **Allow it in [`variables.tf`](../terraform/environments/production/variables.tf).** Add the id + to the `app_default_theme` validation `contains(...)` list, otherwise `terraform plan` will + reject it when a deployer sets it as the default: + + ```hcl + condition = contains(["light", "dark", "system", "blue", "acme-purple"], var.app_default_theme) + ``` + +5. **(Optional) Set it as your deploy-time default.** In `terraform.tfvars`: + + ```hcl + app_default_theme = "acme-purple" + ``` + +The shipped "Blue" theme (`packages/web/src/app/themes/blue.css`) exists as a worked example of all +of the above — feel free to delete it once you have your own brand wired up. Removing it is a +four-line change: delete the file, the import in `layout.tsx`, the `APP_THEMES` entry, and the id +from the `variables.tf` validation list. + +## Rules for component authors + +- **Reach for a semantic token first.** If you're typing `bg-white`, `text-gray-700`, or + `border-zinc-200`, stop and find the matching token. Almost always there is one (`bg-background`, + `text-muted-foreground`, `border-border`). +- **Pair every raw color with a `dark:` variant if you must use one.** `text-gray-900` alone breaks + on dark; `text-gray-900 dark:text-gray-50` works but is still worse than `text-foreground`. Save + the dual-class form for the rare case where the design genuinely diverges from the tokens. +- **Add tokens when needed.** Brand colors (chart series, status pills, custom accents) belong in + the variable layer too — `--chart-1`, `--status-success-strong`, etc. — with a dark counterpart. + This keeps the dark theme honest. +- **Trust portals.** Radix Dialog / Popover / DropdownMenu portals render outside the React tree but + inherit ``, so theme tokens work without extra wiring. + +## Auditing for regressions + +Before merging UI changes, grep for hard-coded colors that aren't paired with `dark:`: + +```bash +rg -n '\b(bg|text|border)-(white|black|gray-|zinc-|slate-|neutral-|stone-)' packages/web/src \ + --glob '!*.test.*' | rg -v 'dark:' +``` + +Each hit is either a missing token replacement or a missing `dark:` pair. Fix both kinds. + +## Related + +- [globals.css](../packages/web/src/app/globals.css) — the source of truth for tokens. +- [providers.tsx](../packages/web/src/app/providers.tsx) — `next-themes` wiring. +- [appearance-settings.tsx](../packages/web/src/components/settings/appearance-settings.tsx) — the + user-facing picker. diff --git a/packages/web/src/app/(app)/session/[id]/page.tsx b/packages/web/src/app/(app)/session/[id]/page.tsx index 87c068917..2709ad455 100644 --- a/packages/web/src/app/(app)/session/[id]/page.tsx +++ b/packages/web/src/app/(app)/session/[id]/page.tsx @@ -1213,14 +1213,14 @@ function ParticipantsList({ {uniqueParticipants.slice(0, 3).map((p) => (
{p.name.charAt(0).toUpperCase()}
))} {uniqueParticipants.length > 3 && ( -
+
+{uniqueParticipants.length - 3}
)} diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index e6d21e297..cc5168138 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -205,6 +205,13 @@ pre code.hljs { --overlay: rgba(0, 0, 0, 0.5); } +/* + * Branded themes (e.g., `.blue`) live in `app/themes/.css` and are imported + * from `app/layout.tsx` after this file. Each branded theme overrides the + * `:root` token defaults above whenever its class is active on ``. + * See `docs/THEMING.md` for the full walkthrough. + */ + html { scroll-behavior: smooth; } diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 442fcf7d2..1c804dbb2 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -3,6 +3,11 @@ import { Geist, Geist_Mono } from "next/font/google"; import { Providers } from "./providers"; import { APP_ICON_URL, APP_NAME } from "@/lib/site-config"; import "./globals.css"; +// Branded themes — each file is self-contained and overrides `:root` tokens +// when its class is active on ``. Imported after `globals.css` so the +// cascade order is correct. Remove a theme by deleting its file, this import +// line, its `APP_THEMES` entry, and its `variables.tf` validation entry. +import "./themes/blue.css"; const geistSans = Geist({ variable: "--font-geist-sans", diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index a35e045eb..ac0cd5077 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -5,6 +5,8 @@ import { ThemeProvider } from "next-themes"; import { SWRConfig } from "swr"; import { Toaster } from "@/components/ui/sonner"; import { SyntaxHighlightTheme } from "@/components/syntax-highlight-theme"; +import { APP_THEME_IDS } from "@/lib/app-themes"; +import { APP_DEFAULT_THEME } from "@/lib/site-config"; async function swrFetcher(url: string): Promise { const res = await fetch(url); @@ -14,7 +16,12 @@ async function swrFetcher(url: string): Promise { export function Providers({ children }: { children: React.ReactNode }) { return ( - + {children} diff --git a/packages/web/src/app/themes/blue.css b/packages/web/src/app/themes/blue.css new file mode 100644 index 000000000..130cf143f --- /dev/null +++ b/packages/web/src/app/themes/blue.css @@ -0,0 +1,58 @@ +/* + * Example branded theme — "Blue". + * + * This file is one self-contained theme. Each named theme lives behind a + * class selector that matches its `id` in `packages/web/src/lib/app-themes.ts`. + * `next-themes` adds the active theme's id as a class on ``, so picking + * "Blue" in Settings → Appearance (or setting `app_default_theme = "blue"` in + * tfvars) makes these rules win. + * + * This file is imported from `app/layout.tsx` after `globals.css`, so the + * cascade order is correct — `.blue` overrides the `:root` defaults whenever + * `` is active. + * + * Blue is light-only by design: with `attribute="class"` only one theme class + * is present on `` at a time, so picking "Blue" replaces (not stacks + * with) "Dark" — a user who wants a dark UI picks "Dark" instead. A separate + * dark variant of a branded palette should be registered as its own theme + * entry (e.g., `{ id: "blue-dark", colorScheme: "dark" }`) with its own + * `themes/blue-dark.css` file, rather than relying on `.blue.dark`. + * + * To replace this with your own brand, copy this file (e.g., to + * `themes/acme-purple.css`), override only the tokens that change, and wire + * it up via the four-step recipe in `docs/THEMING.md`. + * + * To remove the example entirely: + * 1. Delete this file. + * 2. Remove the `import "./themes/blue.css"` line from `app/layout.tsx`. + * 3. Drop the `blue` entry from `APP_THEMES` in `lib/app-themes.ts`. + * 4. Drop `"blue"` from the `app_default_theme` validation list in + * `terraform/environments/production/variables.tf`. + */ +.blue { + --background: #f4f7fb; + --foreground: #0f223a; + + --card: #ffffff; + --card-foreground: #0f223a; + + --popover: #ffffff; + --popover-foreground: #0f223a; + + --primary: #1e4f8a; + --primary-foreground: #ffffff; + + --accent: #2a73c4; + --accent-foreground: #ffffff; + --accent-muted: rgba(42, 115, 196, 0.12); + + --muted: rgba(15, 34, 58, 0.05); + --muted-foreground: #4a647f; + --secondary-foreground: #6b7d92; + + --border: rgba(15, 34, 58, 0.12); + --border-muted: rgba(15, 34, 58, 0.06); + + --input: #ffffff; + --ring: #2a73c4; +} diff --git a/packages/web/src/components/settings/appearance-settings.test.tsx b/packages/web/src/components/settings/appearance-settings.test.tsx new file mode 100644 index 000000000..8ef8afbb6 --- /dev/null +++ b/packages/web/src/components/settings/appearance-settings.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +/// + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { AppearanceSettings } from "./appearance-settings"; +import { APP_THEMES } from "@/lib/app-themes"; + +expect.extend(matchers); + +const setThemeMock = vi.fn(); +let themeState: string | undefined = "system"; + +vi.mock("next-themes", () => ({ + useTheme: () => ({ + theme: themeState, + setTheme: setThemeMock, + resolvedTheme: themeState === "dark" ? "dark" : "light", + }), +})); + +beforeEach(() => { + themeState = "system"; + setThemeMock.mockReset(); + localStorage.clear(); +}); + +afterEach(() => { + cleanup(); +}); + +// The App theme section renders a single now and the initial value is always a string, so React + // should never log this warning. + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + themeState = undefined; + render(); + const messages = errorSpy.mock.calls.map((args) => String(args[0])); + expect(messages.some((m) => m.includes("changing from uncontrolled to controlled"))).toBe( + false + ); + errorSpy.mockRestore(); + }); +}); diff --git a/packages/web/src/components/settings/appearance-settings.tsx b/packages/web/src/components/settings/appearance-settings.tsx index a2cfa3100..d38ae6fb2 100644 --- a/packages/web/src/components/settings/appearance-settings.tsx +++ b/packages/web/src/components/settings/appearance-settings.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTheme } from "next-themes"; import { useSyntaxHighlightPreferences, LIGHT_THEMES, @@ -7,6 +8,7 @@ import { type ColorSchemeMode, type SyntaxHighlightThemeDefinition, } from "@/hooks/use-syntax-highlight-preferences"; +import { APP_THEMES, DEFAULT_APP_THEME } from "@/lib/app-themes"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { SunIcon, MoonIcon, MonitorIcon } from "@/components/ui/icons"; @@ -53,6 +55,7 @@ function ThemeRow({ export function AppearanceSettings() { const { colorSchemeMode, preferredLightTheme, preferredDarkTheme, update } = useSyntaxHighlightPreferences(); + const { theme, setTheme } = useTheme(); return (
@@ -61,6 +64,37 @@ export function AppearanceSettings() { Customize the appearance of the application.

+ {/* App theme section */} +
+

App theme

+

+ Pick the look of the entire app. Default uses the built-in colors; System matches your OS + preference; additional palettes can be registered in code and selected here. +

+ +
+
+
+ Theme +

+ Your choice persists across sessions and overrides the deploy-time default. +

+
+ +
+
+
+ {/* Code Highlighting section */}

Code highlighting

diff --git a/packages/web/src/components/settings/sandbox-settings.tsx b/packages/web/src/components/settings/sandbox-settings.tsx index d7dd46476..cab08764c 100644 --- a/packages/web/src/components/settings/sandbox-settings.tsx +++ b/packages/web/src/components/settings/sandbox-settings.tsx @@ -168,7 +168,7 @@ function SandboxSettingsEditor({ }`} > diff --git a/packages/web/src/components/sidebar/participants-section.tsx b/packages/web/src/components/sidebar/participants-section.tsx index 692932fe6..fbfe782bb 100644 --- a/packages/web/src/components/sidebar/participants-section.tsx +++ b/packages/web/src/components/sidebar/participants-section.tsx @@ -22,16 +22,16 @@ export function ParticipantsSection({ participants }: ParticipantsSectionProps) {participant.name} ) : ( -
+
{participant.name.charAt(0).toUpperCase()}
)} {/* Status indicator */} {participant.status === "active" && ( - + )}
))} diff --git a/packages/web/src/components/syntax-highlight-theme.tsx b/packages/web/src/components/syntax-highlight-theme.tsx index a1cc15215..e20232fbf 100644 --- a/packages/web/src/components/syntax-highlight-theme.tsx +++ b/packages/web/src/components/syntax-highlight-theme.tsx @@ -8,6 +8,7 @@ import { LIGHT_THEMES, DARK_THEMES, } from "@/hooks/use-syntax-highlight-preferences"; +import { getAppTheme } from "@/lib/app-themes"; const LINK_ID = "hljs-theme-link"; @@ -16,15 +17,23 @@ const LINK_ID = "hljs-theme-link"; * user preferences. Must be rendered as a single instance (in Providers). */ export function SyntaxHighlightTheme() { - const { resolvedTheme } = useTheme(); + const { theme, resolvedTheme } = useTheme(); const { colorSchemeMode, preferredLightTheme, preferredDarkTheme } = useSyntaxHighlightPreferences(); useEffect(() => { - // Determine which color scheme is active + // Determine which color scheme is active. When Code Highlighting's mode + // is "system" we follow the active app theme — for "system" itself + // next-themes has already resolved that to light/dark via resolvedTheme; + // for named palettes ("blue", etc.) we read the registry's colorScheme. let activeScheme: "light" | "dark"; if (colorSchemeMode === "system") { - activeScheme = (resolvedTheme as "light" | "dark") ?? "light"; + const appTheme = getAppTheme(theme); + if (appTheme && appTheme.colorScheme !== "system") { + activeScheme = appTheme.colorScheme; + } else { + activeScheme = (resolvedTheme as "light" | "dark") ?? "light"; + } } else { activeScheme = colorSchemeMode; } @@ -47,7 +56,7 @@ export function SyntaxHighlightTheme() { link.href = href; document.head.appendChild(link); } - }, [resolvedTheme, colorSchemeMode, preferredLightTheme, preferredDarkTheme]); + }, [theme, resolvedTheme, colorSchemeMode, preferredLightTheme, preferredDarkTheme]); return null; } diff --git a/packages/web/src/lib/app-themes.ts b/packages/web/src/lib/app-themes.ts new file mode 100644 index 000000000..c5321eeac --- /dev/null +++ b/packages/web/src/lib/app-themes.ts @@ -0,0 +1,94 @@ +/** + * App theme registry. + * + * Built-ins (`light`, `dark`, `system`) are wired to rules in `globals.css`. + * Branded themes live one-per-file in `app/themes/.css` and are imported + * from `app/layout.tsx` after `globals.css`. `next-themes` adds the active + * theme's `id` as a class on ``, so the CSS selector and the registry + * id must match. + * + * `colorScheme` tells the rest of the app whether a theme reads as light or + * dark — primarily so syntax highlighting (`syntax-highlight-theme.tsx`) can + * pick the right hljs stylesheet when a custom palette is active. + * + * Adding a new branded theme: + * 1. Append an entry here. + * 2. Create `app/themes/.css` with a `. { ... }` rule. + * 3. Import it from `app/layout.tsx` after `./globals.css`. + * 4. Add the id to the `app_default_theme` validation list in `variables.tf`. + * + * Each named palette is light-only or dark-only (next-themes' `attribute="class"` + * puts only one theme class on `` at a time, so `.foo.dark` never matches). + * Register a separate `-dark` theme entry + file for a dark variant. + * + * See `docs/THEMING.md` for the full walkthrough. + */ + +/** + * Whether a theme reads as light, dark, or follows the OS preference. + * Reserved values; named palettes should be "light" or "dark" only. + */ +export type AppThemeColorScheme = "light" | "dark" | "system"; + +/** + * One entry in the theme registry. `id` doubles as the CSS class selector + * and the value persisted by next-themes; `label` is the human-readable + * name shown in the Appearance picker; `colorScheme` drives syntax + * highlighting fallback when this theme is active. + */ +export interface AppTheme { + id: string; + label: string; + /** + * Whether this theme reads as light or dark. Drives syntax highlighting: + * `syntax-highlight-theme.tsx` reads this to pick the matching light- or + * dark-mode hljs stylesheet when a custom palette is active. Use "system" + * only for the special System entry (resolves to light/dark via OS pref); + * named palettes ("blue", etc.) should always pick "light" or "dark". + */ + colorScheme: AppThemeColorScheme; +} + +/** + * Registered themes, in the order they appear in the Appearance picker. + * Each entry must have a matching CSS rule keyed by `id` — built-ins live in + * `globals.css`, branded themes in `app/themes/.css`. + */ +export const APP_THEMES: AppTheme[] = [ + { id: "light", label: "Default", colorScheme: "light" }, + { id: "dark", label: "Dark", colorScheme: "dark" }, + { id: "system", label: "System", colorScheme: "system" }, + // Example branded theme. Remove or rename to fit your deployment, and + // adjust the matching file at `app/themes/blue.css`. + { id: "blue", label: "Blue", colorScheme: "light" }, +]; + +/** Theme ids in registration order — passed to next-themes' `themes` prop. */ +export const APP_THEME_IDS = APP_THEMES.map((t) => t.id); + +/** + * Hard-coded fallback used when no deploy-time default is configured or the + * configured value is invalid. "system" preserves the historical behavior. + */ +export const DEFAULT_APP_THEME = "system"; + +/** + * Look up a registered theme by id. Returns `undefined` for unknown ids so + * callers can decide how to handle them (most should fall through to the + * resolved light/dark behavior rather than treating it as an error). + */ +export function getAppTheme(id: string | undefined): AppTheme | undefined { + return APP_THEMES.find((t) => t.id === id); +} + +/** + * Resolve a value from `NEXT_PUBLIC_APP_DEFAULT_THEME` (or any other source) to + * a known theme id. Falls back to `DEFAULT_APP_THEME` when the input is empty + * or doesn't match a registered theme — the deployer has no way to recover + * from a typo in tfvars otherwise. + */ +export function resolveDefaultAppTheme(raw: string | undefined | null): string { + const value = raw?.trim(); + if (!value) return DEFAULT_APP_THEME; + return APP_THEME_IDS.includes(value) ? value : DEFAULT_APP_THEME; +} diff --git a/packages/web/src/lib/site-config.ts b/packages/web/src/lib/site-config.ts index a318dbea5..11e7dcb3b 100644 --- a/packages/web/src/lib/site-config.ts +++ b/packages/web/src/lib/site-config.ts @@ -1,9 +1,19 @@ import { DEFAULT_APP_NAME } from "@open-inspect/shared"; +import { resolveDefaultAppTheme } from "@/lib/app-themes"; export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME?.trim() || DEFAULT_APP_NAME; export const DEFAULT_APP_SHORT_NAME = "Inspect"; +/** + * Default app theme applied on first load before the user picks one. + * Configured at build time via `NEXT_PUBLIC_APP_DEFAULT_THEME` (which is + * driven by the `app_default_theme` tfvar in production). Validated against + * the theme registry — an unknown id falls back to "system" so a typo + * in tfvars doesn't ship a broken UI to end users. + */ +export const APP_DEFAULT_THEME = resolveDefaultAppTheme(process.env.NEXT_PUBLIC_APP_DEFAULT_THEME); + /** * Short brand label shown in the sidebar header. * Defaults to "Inspect" for the built-in brand. Set NEXT_PUBLIC_APP_SHORT_NAME diff --git a/terraform/environments/production/terraform.tfvars.example b/terraform/environments/production/terraform.tfvars.example index 563c82201..0e970df5c 100644 --- a/terraform/environments/production/terraform.tfvars.example +++ b/terraform/environments/production/terraform.tfvars.example @@ -252,6 +252,19 @@ project_root = "../../../" # - Workers Scripts: Edit (to attach the MEDIA_BUCKET binding to the Worker) # r2_media_bucket_name = "" +# Theme applied on first load before the user picks one. Useful when you want +# every new visitor to land on a company-branded theme out of the box — they can +# still switch in Settings → Appearance and their choice persists. Must match a +# registered theme id from packages/web/src/lib/app-themes.ts: +# "light" – the built-in light/Default palette +# "dark" – the built-in dark palette +# "system" – follow the user's OS preference (default) +# "blue" – the demo branded palette shipped as an example +# To ship your own brand: add a `[your-id]` entry to APP_THEMES, add the +# matching CSS rule in packages/web/src/app/globals.css, append the id to the +# variable's validation list in variables.tf, then set this to "your-id". +# app_default_theme = "system" + # ============================================================================= # Initial Deployment Flags # ============================================================================= diff --git a/terraform/environments/production/variables.tf b/terraform/environments/production/variables.tf index 95a55aa51..5f117c965 100644 --- a/terraform/environments/production/variables.tf +++ b/terraform/environments/production/variables.tf @@ -349,6 +349,17 @@ variable "app_icon_url" { default = "" } +variable "app_default_theme" { + description = "Theme applied on first load before a user picks one. Useful when you want every new visitor to land on a company-branded theme out of the box; users can still switch in Settings → Appearance and their choice persists. Must be a registered theme id from packages/web/src/lib/app-themes.ts (built-in: 'light', 'dark', 'system'; the demo registry also ships 'blue'). An unknown id silently falls back to 'system' at runtime." + type = string + default = "system" + validation { + # Keep in sync with APP_THEMES in packages/web/src/lib/app-themes.ts. + condition = contains(["light", "dark", "system", "blue"], var.app_default_theme) + error_message = "app_default_theme must be one of: light, dark, system, blue. To allow another value, add it to APP_THEMES in packages/web/src/lib/app-themes.ts and update this validation list." + } +} + variable "enable_durable_object_bindings" { description = "Enable DO bindings. For initial deployment: set to false (applies migrations), then set to true (adds bindings)." type = bool diff --git a/terraform/environments/production/web-cloudflare.tf b/terraform/environments/production/web-cloudflare.tf index e5717a74d..7f062b545 100644 --- a/terraform/environments/production/web-cloudflare.tf +++ b/terraform/environments/production/web-cloudflare.tf @@ -16,11 +16,12 @@ resource "null_resource" "web_app_cloudflare_build" { environment = { # NEXT_PUBLIC_* vars must be set at build time (inlined into client bundle) - NEXT_PUBLIC_WS_URL = local.ws_url - NEXT_PUBLIC_SANDBOX_PROVIDER = var.sandbox_provider - NEXT_PUBLIC_APP_NAME = var.app_name - NEXT_PUBLIC_APP_SHORT_NAME = var.app_short_name - NEXT_PUBLIC_APP_ICON_URL = var.app_icon_url + NEXT_PUBLIC_WS_URL = local.ws_url + NEXT_PUBLIC_SANDBOX_PROVIDER = var.sandbox_provider + NEXT_PUBLIC_APP_NAME = var.app_name + NEXT_PUBLIC_APP_SHORT_NAME = var.app_short_name + NEXT_PUBLIC_APP_ICON_URL = var.app_icon_url + NEXT_PUBLIC_APP_DEFAULT_THEME = var.app_default_theme } } } @@ -75,6 +76,7 @@ resource "local_file" "web_app_wrangler_production" { NEXT_PUBLIC_APP_NAME = "${var.app_name}" NEXT_PUBLIC_APP_SHORT_NAME = "${var.app_short_name}" NEXT_PUBLIC_APP_ICON_URL = "${var.app_icon_url}" + NEXT_PUBLIC_APP_DEFAULT_THEME = "${var.app_default_theme}" ALLOWED_USERS = "${var.allowed_users}" ALLOWED_EMAIL_DOMAINS = "${var.allowed_email_domains}" UNSAFE_ALLOW_ALL_USERS = "${tostring(var.unsafe_allow_all_users)}" diff --git a/terraform/environments/production/web-vercel.tf b/terraform/environments/production/web-vercel.tf index 98a8f305e..923763c4c 100644 --- a/terraform/environments/production/web-vercel.tf +++ b/terraform/environments/production/web-vercel.tf @@ -79,6 +79,12 @@ module "web_app" { targets = ["production", "preview"] sensitive = false }, + { + key = "NEXT_PUBLIC_APP_DEFAULT_THEME" + value = var.app_default_theme + targets = ["production", "preview"] + sensitive = false + }, # Internal { key = "INTERNAL_CALLBACK_SECRET"