The dashboard supports a light and a dark theme, and each studio can override the brand colour and font. All three layers meet in CSS custom properties.

How the theme is chosen

next-themes is configured in the root layout:
  • apps/saas/config.ts sets enabledThemes: ["light", "dark"] and defaultTheme: "dark".
  • attribute="class" puts .dark on the html element.
  • globals.css declares @variant dark (&:where(.dark, .dark *));, so Tailwind’s dark: variant follows that class and not the OS preference.
ColorModeToggle (in the user menu and on the auth pages) switches the theme.

Tokens

Base tokens

frontend/tooling/tailwind/theme.css (package @repo/tailwind-config) defines the tokens under :root and .dark, then maps each to a Tailwind colour in an @theme block. --line-soft is a divider softer than the hairline. --bg-1 is an inset surface between the page and a card. The radius scale is derived: --radius-sm is 0.6 of --radius, --radius-md 0.8, --radius-lg 1, up to --radius-4xl at 2.6. --font-display resolves to Heebo (--font-heebo from the root layout) and is used through font-display on headings.

App level overrides

apps/saas/app/globals.css imports the base theme and adds a few tokens of its own: It also re-points three dark tokens: --background to #15151f, and --card and --popover to #1e1e2b. So the dark surfaces the dashboard actually paints are those values, not the ones in theme.css.

Global base rules

Also in globals.css:
  • Every border defaults to var(--border).
  • svg.lucide gets stroke-width: 1.5.
  • cursor: pointer is set once for enabled buttons, [role="button"], [role="tab"], links with href, label[for] and summary. Disabled controls get cursor: not-allowed. Do not set cursors per element.
  • A container utility (centered, max-width 7xl, 1.5rem inline padding) and a no-scrollbar utility.

Studio branding

Each studio’s Branding record is stored by the core API on the studio. OrgTheme injects it as a style tag on every studio page. brandingCss(branding) in modules/shared/lib/branding.ts emits overrides for both themes: Both the semantic token and its --color-* twin are set. Tailwind 4 resolves --color-* at :root, so a preview that scopes overrides to a wrapper element has to set the --color-* variables directly. Supporting functions in the same file:
  • parseBranding(raw) validates the untyped JSON from the API. Colours must match a hex pattern, anything invalid becomes null.
  • readableForeground(hex) picks black or white text for a background by luminance.
  • googleFontHref(fontFamily) returns a Google Fonts stylesheet URL for fonts in FONT_OPTIONS.
  • FONT_OPTIONS, PRESET_PALETTES, HOME_THEMES (classic, glass, poster, bento) feed the branding editor.
Because the primary colour is a studio choice, never assume bg-primary is dark or light. Pair it with text-primary-foreground.

Writing themed UI

Use tokens for every neutral: Saturated brand and status colours (a blue badge, an orange warning) may be literals. They read on both themes. When a tint is pale, give it a dark counterpart on the same element:
For CSS written outside Tailwind (a feature stylesheet or a CSS string), declare a custom property and re-point it under .dark in the same file. No shadows on in page containers. Separate surfaces with a border.

The theme check

frontend/tools/check-theme-tokens.mjs (pnpm check:theme) fails when a themed surface uses a colour that only works in one theme.

What it scans

  • Roots: apps/saas/ and packages/ui/. The marketing app is out of scope. It has a fixed palette and no dark mode.
  • Files: .ts, .tsx, .css. Ignores node_modules, .next, .turbo, dist, generated, public.
  • Comment lines are skipped.

What it flags

Pure #fff, #ffffff, #000 and #000000 are always allowed. A saturated dark colour is allowed, because it still reads on the dark shell.

Escapes

  • A themed custom property. Declaring --my-surface: #fafafa is fine when the same file also declares --my-surface under a .dark selector. The script parses the file for properties declared inside .dark blocks.
  • theme-ok. A comment containing theme-ok: <reason> on the line, or within the three lines above it, silences the finding. Three lines is enough to sit above an opening tag and its className.
  • ALWAYS_ONE_THEME. A list of files and patterns that are one fixed palette on purpose. Each entry needs a comment saying why.
Current exemptions: the mobile preview theme and branding files, BrandingView, the home banner CTA styles, every *AppPreview.tsx, HomeSkins, the form builder’s simulator files, everything under app/(start)/, PlanGaugePicker, the AutoFit sync bar, SmartsendLogo, the select-size-preview harness and all .test.ts files. They paint the trainee app, a standalone branded flow, or a third party logo.

When it runs

It is not a step in .github/workflows/ci.yml.

Phone previews

Components that draw the trainee app inside the dashboard use APP_DARK and APP_LIGHT from modules/shared/lib/mobile-preview-theme.ts with inline styles. They show the app’s palette whatever the dashboard theme is. That is why they are exempt from the check, and why ClientAppPhoneFrame takes a theme prop instead of reading tokens.