Theme
A project themes its admin app with one file, protobase.theme.css at its root, beside protobase.config.ts. It sets the values of the app’s tokens, the CSS variables every part of the app and every plugin draws with, for light and for dark, and nothing else:
:root { --primary: #0d9488; --primary-hover: #0f766e; --primary-soft: #f0fdfa; --primary-text: #0f766e; --radius: 10px; --font: Inter, ui-sans-serif, system-ui, sans-serif;}
[data-theme="dark"] { --primary: #2dd4bf; --primary-foreground: #042f2e; --primary-soft: #11302c; --primary-text: #5eead4;}Theming an app walks through one.
Why values only
Section titled “Why values only”A stylesheet that reaches into the app with selectors, .sidebar a or button.primary, depends on the app’s markup, which every release may change; it breaks quietly, and only on the screens it touched. A theme that sets token values depends on nothing but the token names, which are a promise like the plugin API:
- The app reads every color, its radii and its fonts from these tokens, and so do plugins, whose utilities read them too (
bg-primaryisbackground-color: var(--primary)). - Tokens are only added. A token that is renamed keeps its old name as well, so a theme never stops applying.
- A token the theme leaves out keeps the app’s value, and a value the app no longer reads is ignored, so a theme written for one release works on the next with nothing rebuilt.
Changing how one part of the app looks, beyond its tokens, is not a theme’s job: replace that part with a plugin instead, such as a record card or a field type.
The file
Section titled “The file”- Selectors:
:rootfor the light values, which hold for dark too unless[data-theme="dark"]sets its own. Nothing else: no other selector, no nesting, no@mediaor@import. - Declarations: tokens only,
--name: value. Any other property, such ascolororfont-size, fails the build. - Fonts:
@font-facerules are allowed, for a font the theme names in--font. Serve its files from wherever you like; a path such as/fonts/inter.woff2is the app’s origin, which a static host serves from the bundle’spublic/only when the project puts it there.
protobase build checks the file, minifies it and writes it to the bundle’s public/protobase.theme.css, empty when the project has none. A rule it does not allow fails the build, naming the line; a token the app does not read prints a warning, a likely typo, and the build goes on. protobase dev serves the file the same way, checked on every request, so an edit shows on the next reload.
How the app links it
Section titled “How the app links it”The app’s index.html links /protobase.theme.css after its own stylesheets, so the theme applies before anything draws, the sign-in page included, and needs no request to the API. The app’s own token values sit in a cascade layer (@layer base) and the theme’s in none, so the theme wins whatever order the browser loads them in. Hosts serve the file with no-cache, so a new build of the theme shows on the next load.
Light, dark or the system
Section titled “Light, dark or the system”Each user picks light, dark or “System” with the theme switch in the profile menu, a visitor who is not signed in with the one above “Sign in” at the bottom of the sidebar, and the app follows their system’s setting until they pick one. The choice is kept in their browser (localStorage, under protobase:theme), so it holds on every visit and in every tab; with storage blocked the app follows the system.
The app draws dark by setting data-theme="dark" on the <html> element, and data-theme="light" for light. The app’s index.html sets it with a small script in its <head>, before the first paint, so a dark page never flashes white while the app loads, the sign-in page included. Native parts of the page, such as scrollbars and date pickers, follow it too (color-scheme).
Pictures, file previews and maps keep their own colors in dark: a map draws in the style the project names for it. A theme’s [data-theme="dark"] values win over the app’s dark ones, as its :root values win over the app’s light ones, and a :root value without a dark one holds in dark too.
Tokens
Section titled “Tokens”Every token, with the app’s light and dark values. Colors take any CSS color.
| Token | Light | Dark |
|---|---|---|
--background |
#ffffff |
#0f0f11 |
--surface |
#fafafa |
#151518 |
--muted |
#f4f4f5 |
#1f1f23 |
--border |
#e4e4e7 |
#27272b |
--border-strong |
#d4d4d8 |
#3a3a40 |
--foreground |
#18181b |
#f4f4f5 |
--muted-foreground |
#71717a |
#a1a1aa |
--faint-foreground |
#a1a1aa |
#71717a |
--primary |
#2563eb |
#3b82f6 |
--primary-hover |
#1d4ed8 |
#60a5fa |
--primary-foreground |
#ffffff |
#ffffff |
--primary-soft |
#eff6ff |
#172036 |
--primary-border |
#bfdbfe |
#1e3a6e |
--primary-text |
#1d4ed8 |
#93c5fd |
--success |
#16a34a |
#22c55e |
--success-soft |
#f0fdf4 |
#0f2418 |
--success-text |
#15803d |
#86efac |
--warning |
#d97706 |
#f59e0b |
--warning-soft |
#fffbeb |
#2a1f0a |
--warning-text |
#b45309 |
#fcd34d |
--danger |
#dc2626 |
#ef4444 |
--danger-soft |
#fef2f2 |
#2a1214 |
--danger-text |
#b91c1c |
#fca5a5 |
--violet |
#7c3aed |
#8b5cf6 |
--violet-soft |
#f5f3ff |
#20173a |
--violet-text |
#6d28d9 |
#c4b5fd |
--sidebar |
#f8f8f9 |
#121215 |
--chart-1 |
#2a78d6 |
#3987e5 |
--chart-2 |
#eb6834 |
#d95926 |
--chart-3 |
#1baf7a |
#199e70 |
--chart-4 |
#eda100 |
#c98500 |
--chart-5 |
#e87ba4 |
#d55181 |
--chart-6 |
#008300 |
#008300 |
--chart-7 |
#4a3aa7 |
#9085e9 |
--chart-8 |
#e34948 |
#e66767 |
--shadow-pop |
0 8px 24px -4px rgb(0 0 0 / 0.12), 0 2px 6px rgb(0 0 0 / 0.06) |
0 8px 24px -4px rgb(0 0 0 / 0.6), 0 2px 6px rgb(0 0 0 / 0.4) |
--backdrop |
#18181b |
#000000 |
--radius |
6px |
the light value |
--font |
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif |
the light value |
--font-code |
ui-monospace, "SF Mono", Menlo, Consolas, monospace |
the light value |
What they color:
| Tokens | |
|---|---|
--background, --surface, --muted, --sidebar |
The page, raised surfaces such as cards and table headers, hovered and quiet areas, and the sidebar. |
--border, --border-strong |
Dividers and card edges, and inputs and buttons. |
--foreground, --muted-foreground, --faint-foreground |
Text: the main, the secondary (help, labels) and the faintest (empty values, placeholders). |
--primary and its group |
The main buttons and links (--primary, --primary-hover, --primary-foreground on it), selected and highlighted areas (--primary-soft, --primary-border) and text in the primary color (--primary-text). |
--success, --warning, --danger, --violet and their groups |
Statuses and messages: the color itself, a soft background and readable text on it. |
--chart-1 to --chart-8 |
The series of a chart, in this order: a donut’s values, and --chart-1 for bars and lines. The defaults stay apart for colour-blind readers, so a theme that changes them should keep them as distinct. |
--shadow-pop |
The shadow of menus, popovers and the command palette. |
--backdrop |
The dim behind dialogs, sheets and the phone drawer, and the shade at a table’s edge while it scrolls sideways; drawn see-through, so give it a solid color. |
--radius |
The corners: rounded-md is --radius, rounded-lg two pixels more, rounded-sm two less and rounded-xl six more. |
--font, --font-code |
The app’s text, and identifiers and code. |
The list is packages/ui/src/tokens.css, and a test fails when one of its tokens disappears.
Logo and favicon
Section titled “Logo and favicon”The sidebar shows Protobase’s logo, its mark alone when the sidebar shows only icons. A project shows its own with shell.logo in protobase.ui.tsx, a component drawn in its place (Shell slots); it draws with these tokens like the rest of the app.
The browser tab shows Protobase’s mark. A project shows its own with a protobase.favicon.svg at its root, beside protobase.theme.css: the app’s index.html links /protobase.favicon.svg, which protobase build writes to the bundle’s public/, the project’s file or Protobase’s without one, and which protobase dev serves the same way. An SVG can follow the system’s light or dark with a prefers-color-scheme media query inside it, as Protobase’s does.
In a plugin
Section titled “In a plugin”A plugin’s utilities read the same tokens, with Tailwind’s default as the fallback where an app sets none: bg-primary is var(--primary), rounded-md is var(--radius, 6px) and font-sans is var(--font, ...). So a plugin follows the theme of every app that loads it, without knowing it. Write classes such as bg-primary, text-muted-foreground and border-border rather than fixed colors, and the plugin looks at home in light and dark alike. Text takes the app’s base size, 14px, without a class; text-body restates it where a parent sets another. Where a plugin needs a value of its own for dark, a dark: class such as dark:bg-muted follows the theme the user picked, not only their system’s.