Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/SETUP_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
204 changes: 204 additions & 0 deletions docs/THEMING.md
Original file line number Diff line number Diff line change
@@ -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 `<html>`, 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(<id>)` from `next-themes`. That:

- Sets the active theme's `id` as a class on `<html>` (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 `<html>` 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/<id>.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 `<html>` 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 `<html>`.

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 `<html class="dark">`, 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.
4 changes: 2 additions & 2 deletions packages/web/src/app/(app)/session/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1213,14 +1213,14 @@ function ParticipantsList({
{uniqueParticipants.slice(0, 3).map((p) => (
<div
key={p.userId}
className="w-8 h-8 rounded-full bg-card flex items-center justify-center text-xs font-medium text-foreground border-2 border-white"
className="w-8 h-8 rounded-full bg-card flex items-center justify-center text-xs font-medium text-foreground border-2 border-background"
title={p.name}
>
{p.name.charAt(0).toUpperCase()}
</div>
))}
{uniqueParticipants.length > 3 && (
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-xs font-medium text-foreground border-2 border-white">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-xs font-medium text-foreground border-2 border-background">
+{uniqueParticipants.length - 3}
</div>
)}
Expand Down
7 changes: 7 additions & 0 deletions packages/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@ pre code.hljs {
--overlay: rgba(0, 0, 0, 0.5);
}

/*
* Branded themes (e.g., `.blue`) live in `app/themes/<id>.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 `<html>`.
* See `docs/THEMING.md` for the full walkthrough.
*/

html {
scroll-behavior: smooth;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html>`. 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",
Expand Down
9 changes: 8 additions & 1 deletion packages/web/src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(url: string): Promise<T> {
const res = await fetch(url);
Expand All @@ -14,7 +16,12 @@ async function swrFetcher<T>(url: string): Promise<T> {

export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ThemeProvider
attribute="class"
themes={APP_THEME_IDS}
defaultTheme={APP_DEFAULT_THEME}
enableSystem
>
<SWRConfig value={{ fetcher: swrFetcher, revalidateOnFocus: true, dedupingInterval: 2000 }}>
<SessionProvider>
{children}
Expand Down
58 changes: 58 additions & 0 deletions packages/web/src/app/themes/blue.css
Original file line number Diff line number Diff line change
@@ -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 `<html>`, 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
* `<html class="blue">` is active.
*
* Blue is light-only by design: with `attribute="class"` only one theme class
* is present on `<html>` 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;
}
Loading