Skip to main content

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.
  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). 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 — treat that table as the source of truth. Summary of the families: 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

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:
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).