Theming in the kit has two layers that are easy to conflate, so let’s name them up front:
- Per-tenant branding — the Multitenancy module owns a
TenantThemeaggregate (colour palettes, brand assets, typography, layout) with permission-gated read/update/reset endpoints and an editor in the admin console. Themes are data, not deploys — editing one never requires a rebuild. - Per-user appearance — the dashboard ships a rich appearance system (light/dark/system mode, accent colour, font, density, reduced motion) that each user controls from Settings → Appearance and that persists in localStorage.
The TenantTheme store + editor are fully wired end-to-end on the backend and admin side. The dashboard does not yet read TenantTheme to repaint its chrome per tenant — its visual identity today comes from the per-user appearance system. If you want full white-label rendering, the wiring point is small and described below.
The TenantTheme model
The aggregate lives at src/Modules/Multitenancy/Modules.Multitenancy/Domain/TenantTheme.cs; the API exchanges a structured TenantThemeDto (light palette, dark palette, brand assets, typography, layout).
Palette
Light mode + dark mode values for nine roles:
| Role | Default light | Default dark | Used for |
|---|---|---|---|
| Primary | #2563EB | #38BDF8 | Buttons, links, accents |
| Secondary | #0F172A | #94A3B8 | Secondary buttons, badges |
| Tertiary | #6366F1 | #818CF8 | Tertiary accents, dividers |
| Background | #F8FAFC | #0B1220 | App background |
| Surface | #FFFFFF | #111827 | Cards, sheets, dialogs |
| Error | #DC2626 | #F87171 | Destructive actions, error banners |
| Warning | #F59E0B | #FBBF24 | Cautionary states |
| Success | #16A34A | #22C55E | Success states |
| Info | #0284C7 | #38BDF8 | Informational states |
Brand assets
LogoUrl— light-mode logo.LogoDarkUrl— dark-mode logo.FaviconUrl— browser tab icon.
Uploads go through the kit’s storage service — the update endpoint accepts inline image data, stores it, and replaces (and cleans up) the previous asset.
Typography
FontFamily— body font (defaultInter, sans-serif).HeadingFontFamily— display font for headings (defaultInter, sans-serif).FontSizeBase— body font size in px (default 14).LineHeightBase— body line height (default 1.5).
Layout
BorderRadius— radius for cards, buttons, inputs (CSS string, default4px).DefaultElevation— shadow strength (default 1).
Default theme
IsDefault marks one theme as the platform default. Only the root tenant can set it; any tenant without its own saved theme resolves to the kit’s built-in defaults.
The endpoints
All three are current-tenant-scoped — they act on whichever tenant the request’s tenant header resolves to (there’s no {id} in the route) — and permission-gated:
| Endpoint | Permission | Does |
|---|---|---|
GET /api/v1/tenants/theme | Tenants.ViewTheme | Returns the tenant’s theme, or the defaults if none is saved |
PUT /api/v1/tenants/theme | Tenants.UpdateTheme | Upserts the theme (validated — colour formats, font sizes) |
POST /api/v1/tenants/theme/reset | Tenants.UpdateTheme | Resets every field to the kit defaults |
Reads are cached via HybridCache (1 hour distributed / 2 minutes local) and invalidated on every update or reset, so theme fetches don’t hit the database per request.
Editing themes in the admin console
The branding card on the admin console’s tenant detail page is the operator-facing editor. It scopes each call to the target tenant by overriding the tenant header — only root operators get past that override — and covers the light + dark palettes plus brand assets. Typography and layout exist on the DTO but are intentionally left out of the v1 editor; wire them in if your product needs them.
Save persists via PUT /api/v1/tenants/theme; Reset calls POST /api/v1/tenants/theme/reset. Both invalidate the server-side cache immediately.
The dashboard’s appearance system (per-user)
What a dashboard user can customise today is personal, not tenant-wide — Settings → Appearance:
- Mode — light / dark / system, applied with a View Transitions crossfade.
- Accent — preset accent colours or a fully custom hue, applied as the eleven
--brand-*CSS custom-property stops. - Font, density, reduced motion — per-user toggles.
Everything persists in localStorage (fsh.theme, accent/font/density keys) and applies before the app paints, so there’s no flash of the wrong mode. The dashboard’s neutral greys are deliberately untinted (zero chroma) so whatever accent the user — or eventually the tenant — picks does the branding work without fighting a tinted chassis.
Rendering TenantTheme in your own frontend
The server side is done; consuming it is the part you own. The shape:
- After login, call
GET /api/v1/tenants/theme(the signed-in user needsTenants.ViewTheme). - Map the DTO onto CSS custom properties on
document.documentElement— palette roles to colour variables, typography to--font-*,BorderRadiusto your radius token. - Swap the logo / favicon from
BrandAssets. - Cache the last-applied theme in localStorage and re-apply it synchronously in
index.htmlbefore React mounts, so returning users see their brand on first paint instead of a default flash.
Because every kit component already resolves colours through CSS variables, step 2 is a mapping exercise, not a redesign.
What theming doesn’t do
- It’s not a full white-label. The component library (sidebar shape, navigation pattern, dialog conventions) stays the same. Theming changes colours, fonts, and assets; it doesn’t change layout.
- It doesn’t theme the admin console. Admin is for your operators; it stays in the kit’s own palette so operators have a consistent surface across tenants.
- It doesn’t ship a theme marketplace. Tenants get one theme each; there’s no “browse themes” gallery. If you want that, add a
ThemeTemplateaggregate with a one-click apply.
Related
- Multitenancy module — the
TenantThemeaggregate + endpoints. - Tenant dashboard — the per-user appearance system.
- Admin console — the branding editor surface.