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)
- 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.
- Tailwind arbitrary color classes:
- 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.
- New color = new token, defined in every scope. A token added to
app/themes.cssmust have values for:root(light),html.dark(current dark — must equal what staging renders today), and thedark-variant-bscope, plus a registration intailwind.config.ts. 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.cssis 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.tsregisters each token asrgb(var(--token) / <alpha-value>), producing classes likebg-surface-card,border-edge-subtle,text-content-title.- The variant attribute
data-theme-variantis stamped on<html>during SSR from the PostHog flagtheme-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=controlto any URL to compare against the current palette (sticky per tab;?theme_variant=dark-variant-brestores it). The override never runs in production.
Token catalog
The canonical list with per-theme values lives in the header comment ofapp/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):- Give the element a stable class name (e.g.
chat-submit,chat-input-form). - Add a rule at the bottom of
app/themes.cssunder the variant selector:
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 nodark:twin and isn’t inside anisDarkModeternary, it may stay literal. - Components carrying a literal
darkclass (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 tohtml.dark, but the nested class activatesdark: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)
- Find the element’s staging colors:
git show origin/staging:<file>— the control values must equal these, not what you think looks right. - Add the variable to all three scopes in
app/themes.cssand to the header table, with hex comments. - Register it in
tailwind.config.tsunder the right family (surface/edge/content) asrgb(var(--x) / <alpha-value>). - Use it via classes or
rgb(var(--x))inline. - Verify: run the app locally
(
?theme_variant=controlvs?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 readgetComputedStyleper scope. Note: this app sendsX-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 buildfails onno-unused-vars— removing anisDarkModeusage can strand the declaration).