How the theme is chosen
next-themes is configured in the root layout:
apps/saas/config.tssetsenabledThemes: ["light", "dark"]anddefaultTheme: "dark".attribute="class"puts.darkon thehtmlelement.globals.cssdeclares@variant dark (&:where(.dark, .dark *));, so Tailwind’sdark: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 inglobals.css:
- Every border defaults to
var(--border). svg.lucidegetsstroke-width: 1.5.cursor: pointeris set once for enabled buttons,[role="button"],[role="tab"], links withhref,label[for]andsummary. Disabled controls getcursor: not-allowed. Do not set cursors per element.- A
containerutility (centered,max-width7xl, 1.5rem inline padding) and ano-scrollbarutility.
Studio branding
Each studio’sBranding 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 becomesnull.readableForeground(hex)picks black or white text for a background by luminance.googleFontHref(fontFamily)returns a Google Fonts stylesheet URL for fonts inFONT_OPTIONS.FONT_OPTIONS,PRESET_PALETTES,HOME_THEMES(classic,glass,poster,bento) feed the branding editor.
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:
.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/andpackages/ui/. The marketing app is out of scope. It has a fixed palette and no dark mode. - Files:
.ts,.tsx,.css. Ignoresnode_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: #fafafais fine when the same file also declares--my-surfaceunder a.darkselector. The script parses the file for properties declared inside.darkblocks. theme-ok. A comment containingtheme-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 itsclassName.ALWAYS_ONE_THEME. A list of files and patterns that are one fixed palette on purpose. Each entry needs a comment saying why.
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 useAPP_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.