> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# THEMING

# Theming & Semantic Color Tokens — the standard

**Audience:** every human and AI agent writing UI in this repo, and CodeRabbit
when reviewing PRs. If a PR adds or changes colors and does not follow this
document, flag it in review and point here.

## TL;DR for reviewers (CodeRabbit: enforce these)

1. **No new hardcoded color values in themable UI.** Reject/flag any new:
   * Tailwind arbitrary color classes: `bg-[#0A1B2C]`, `dark:text-[#BBD3DD]`,
     `border-[#00293E]`, `hover:bg-[#…]`, `from-[#…]`, etc.
   * Inline style color literals: `background: '#031822'`,
     `color: isDarkMode ? '#95B3C2' : …`, `stroke="#15D49F"`,
     `linear-gradient(… #00121B …)`.
     Use a semantic token instead (see catalog below). Allowed exceptions are
     listed in [When literals are OK](#when-literals-are-ok).
2. **Rule #1 — current-theme fidelity.** The live light and dark themes must
   render **byte-identical to staging**. Any styling change whose visual
   effect should only appear on an experiment variant must be scoped to that
   variant (see [Variant-scoped changes](#variant-scoped-changes)). A PR that
   changes how control light/dark renders needs an explicit callout in its
   description.
3. **New color = new token, defined in every scope.** A token added to
   `app/themes.css` must have values for `:root` (light), `html.dark`
   (current dark — must equal what staging renders today), and the
   `dark-variant-b` scope, plus a registration in `tailwind.config.ts`.
4. **`dark:bg-surface` + `dark:bg-[#hex]` mixed in one PR** is a smell:
   the author found the token system and ignored it for some elements.

## Architecture

* **`app/themes.css`** is the single source of truth for every theme's
  colors. Three scopes today:
  * `:root` — light (default)
  * `html.dark` — current dark palette (**staging-frozen**: these values may
    not drift from what production renders)
  * `html.dark[data-theme-variant='dark-variant-b']` — the experiment arm
  * future light variants: `html:not(.dark)[data-theme-variant='…']`
* Tokens are **R G B channel triplets** (`--surface: 32 32 32;`) so
  Tailwind's `<alpha-value>` opacity modifiers work (`bg-surface/70`).
* **`tailwind.config.ts`** registers each token as
  `rgb(var(--token) / <alpha-value>)`, producing classes like
  `bg-surface-card`, `border-edge-subtle`, `text-content-title`.
* The variant attribute `data-theme-variant` is stamped on `<html>` during
  SSR from the PostHog flag `theme-variant-v01`
  (`lib/theme/theme-variant.ts` + `app/layout.tsx`). No attribute = control.
* **Non-production always renders `dark-variant-b`.** Staging, PR previews
  and local dev force the variant so it is the default thing you see and
  QA; production stays flag-driven. Append `?theme_variant=control` to any
  URL to compare against the current palette (sticky per tab;
  `?theme_variant=dark-variant-b` restores it). The override never runs in
  production.

## Token catalog

The canonical list with per-theme values lives in the header comment of
[`app/themes.css`](../app/themes.css) — treat that table as the source of
truth. Summary of the families:

| Family | Tokens | Use for |
| - | - | - |
| Backgrounds | `page`, `surface`, `surface-card`, `surface-panel`, `surface-neutral`, `surface-tint`, `surface-shell`, `surface-widget` | page bg, cards, panels, dashboard wells/widgets |
| Controls | `surface-raised(-hover)`, `surface-input`, `surface-selected`, `surface-tile(-hover)` | buttons, pills, form inputs/selects, segments, institution tiles |
| Depth | `surface-inset`, `surface-well(-hover)`, `surface-dip`, `surface-popover` | stat tiles, recessed wells, dropdown/popover panels |
| Rows & states | `surface-item`, `surface-item-alert`, `surface-hover` | list/account rows, error rows, item hovers |
| Special | `surface-inverse`, `surface-highlight`, `surface-info`, `surface-danger(-hover)`, `glow-1/2/3` | inverse chips, highlight cards, info/danger chips, decorative glows |
| Borders | `edge`, `edge-subtle`, `edge-panel`, `edge-strong`, `edge-emphasis`, `edge-info(-hover)`, `edge-highlight` | pick by prominence, not by eyeballing a hex |
| Text | `content-body`, `content-muted`, `content-soft`, `content-faint`, `content-placeholder`, `content-dim`, `content-ghost`, `content-title`, `content-label`, `content-info`, `content-highlight` | body → titles → labels → placeholders → hints |
| Accents | `cta`, `cta-hover` | the product green; accents intentionally do NOT change per variant |

**Choosing a token:** pick by *role*, then confirm the control-dark value
matches (Δ ≤ 4 per RGB channel) what the element renders on staging today.
If no token matches within Δ4, add a new one — do not bend an existing
token's value and do not ship the raw hex.

## How to write themable styles

```tsx theme={null}
// ✅ Class-based (preferred)
<div className="bg-surface-card border border-edge-subtle text-content-body" />

// ✅ Single class covers all themes — no dark: pair needed when the token's
// light value matches the light design:
<Card className="bg-surface-tint" />

// ✅ Dark-only differences keep the dark: prefix (light rendering untouched):
<span className="text-[#727272] dark:text-content-title" />

// ✅ Inline styles / SVG / gradients / JS-driven colors:
style={{ background: 'rgb(var(--surface-well))' }}
style={{ background: 'linear-gradient(rgb(var(--page) / 0), rgb(var(--page)))' }}
<circle stroke="rgb(var(--cta))" />
e.currentTarget.style.background = 'rgb(var(--surface-well-hover))'

// ✅ isDarkMode ternaries: dark side uses the var, light side stays literal
// unless the token's light value matches exactly:
color: isDarkMode ? 'rgb(var(--content-label))' : '#7B7B7B'

// ❌ Never:
<div className="bg-white dark:bg-[#031822]" />
style={{ background: isDarkMode ? '#002131' : '#FFF' }}
```

## Variant-scoped changes

When a change should apply **only** to an experiment variant (a different
disabled state, a removed border, different spacing):

1. Give the element a stable class name (e.g. `chat-submit`,
   `chat-input-form`).
2. Add a rule at the bottom of `app/themes.css` under the variant selector:

```css theme={null}
html.dark[data-theme-variant='dark-variant-b'] .chat-input-form {
  border-color: transparent;
}
```

Control and light keep their exact staging styling; the variant gets the new
behavior. **Do not** change the shared classes/markup to achieve a
variant-only effect.

## When literals are OK

* **Email templates** (`app/api/send-daily-email*`, any HTML email): CSS
  variables do not work in email clients — literal hexes required.
* **Single-mode colors with no light/dark counterpart**: elements that render
  the same color in both modes by design (e.g. one-off illustrative accents,
  the \~28 bare `text-[#BBD3DD]` on always-dark surfaces). If it has no
  `dark:` twin and isn't inside an `isDarkMode` ternary, it may stay literal.
* **Components carrying a literal `dark` class** (e.g. the user chat bubble
  on `#005279`): token classes inside such a subtree resolve to **light**
  variable values in light mode (the vars are scoped to `html.dark`, but the
  nested class activates `dark:` variants). Use literal colors inside these
  components — tokens will silently break them.
* Chart/data-viz palettes driven by JS constants consumed by canvas/chart
  libraries that cannot read CSS vars.

## Adding a new token (checklist)

1. Find the element's **staging** colors:
   `git show origin/staging:<file>` — the control values must equal these,
   not what you think looks right.
2. Add the variable to all three scopes in `app/themes.css` **and** to the
   header table, with hex comments.
3. Register it in `tailwind.config.ts` under the right family
   (`surface`/`edge`/`content`) as `rgb(var(--x) / <alpha-value>)`.
4. Use it via classes or `rgb(var(--x))` inline.
5. Verify: run the app locally
   (`?theme_variant=control` vs `?theme_variant=dark-variant-b`, plus light)
   and confirm control matches staging. Headless probe pattern used in this
   repo's history: inject an element with the class and read
   `getComputedStyle` per scope. Note: this app sends
   `X-Frame-Options: SAMEORIGIN`, so iframe-based previews can't render it —
   use a real/headless browser.

## PR checklist (authors)

* [ ] No new hardcoded color classes or inline color literals outside the
  allowed exceptions.
* [ ] New tokens defined in all scopes + registered in Tailwind + added to
  the themes.css header table.
* [ ] Control light/dark rendering is unchanged vs staging (or the PR
  description explicitly calls out and justifies the change).
* [ ] Variant-only styling implemented via the variant scope, not by editing
  shared styles.
* [ ] `npx eslint <touched files>` clean (`next build` fails on
  `no-unused-vars` — removing an `isDarkMode` usage can strand the
  declaration).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.