Theming & White-Labeling¶
The .NET UIs (Blazor admin, Public web, AuthServer) use the ABP LeptonX theme. The Next.js realtime frontend uses Tailwind CSS v4 with its own brand palette. Both worlds are driven by one shared white-labeling configuration so a single tenant config controls names, logos and brand assets across every app.
This page explains the two theming systems, how this codebase wires LeptonX per host, how the custom white-labeling layer feeds branding into ABP and out to the WebAssembly client, and the gotchas that bite when you change any of it.
Two theming systems, one branding source
LeptonX (ABP) and Tailwind (Next.js) are independent styling engines and are not kept in
visual lock-step automatically. What they share is the WhiteLabelingOptions configuration
section (app name, logos, favicon, footer links, external API keys) — see
White-labeling below.
At a glance¶
flowchart TD
cfg["appsettings.<tenant>.json<br/>WhiteLabelingOptions section"]
subgraph dotnet[".NET hosts (LeptonX)"]
auth["Cargonerds.AuthServer<br/>MVC LeptonX · Light"]
pub["Cargonerds.Web.Public<br/>MVC LeptonX · System · TopMenu"]
host["Cargonerds.Blazor (host)<br/>WASM bundling · side-menu"]
client["Cargonerds.Blazor.Client<br/>Blazor LeptonX · Light · SideMenu<br/>+ MudLeptonTheme"]
end
subgraph next["Next.js (Tailwind)"]
rt["frontend/realtime<br/>Tailwind v4 @theme palette"]
end
cfg --> bind["AddOptions<WhiteLabelingOptions>().Bind(...)<br/>(CargonerdsDomainSharedModule)"]
bind --> auth
bind --> pub
bind --> host
host -->|"/appsettings.json merge<br/>(public subset only)"| client
bind -->|"CargonerdsAppConfigContributor<br/>(public subset only)"| client
cfg -. external API keys, brand colors are duplicated by hand .-> rt
auth --> brand["CargonerdsBrandingProvider<br/>(replaces DefaultBrandingProvider)"]
pub --> brand
client --> brand
White-labeling¶
White-label values are bound from the WhiteLabelingOptions configuration section. The options
class lives in the Hub module at
modules/hub/src/Hub.Domain.Shared/WhiteLabeling/WhiteLabelingOptions.cs and is registered as an
ABP-style options object in CargonerdsDomainSharedModule:
context.Services.AddOptions<WhiteLabelingOptions>()
.Bind(configuration.GetSection(nameof(WhiteLabelingOptions)));
The default ("rohlig") tenant config lives in src/Cargonerds.AppHost/appsettings.rohlig.json and
is propagated to each host via the WHITE_LABEL_SETTINGS_PATH mechanism (the AppHost calls
.WithWhiteLabelSettingsPath() on AuthServer, the API host and the Blazor host — see
.NET Aspire Integration and the
Configuration Reference).
Options¶
WhiteLabelingOptions properties (those marked [PublicConfiguration] are the only ones shipped to
browsers — see Public vs. private config):
| Key | [PublicConfiguration] |
Purpose |
|---|---|---|
AppName |
yes | Application display name (e.g. Realtime) |
CompanyName / CompanyNameShort |
yes | Company name (e.g. Röhlig Logistics / Röhlig) |
Logo / AppLogo |
yes | Logo URLs |
BackgroundImage / BackgroundVideo |
yes | Login/landing background assets |
Favicon |
yes | Favicon URL |
DisableCustomUserName |
yes | Hide custom username field |
AdditionalJavaScriptFiles / AdditionalStyleSheetFiles |
yes | Injected custom assets |
CopyrightLinks / FooterLinks |
yes (FooterLink is public) |
Footer links |
ExternalApis |
partial | Third-party config; only MapTiler is public, the rest (Google, Algolia, PricingManager, Eadapter) are server-side only |
EntraLogins |
no | Microsoft Entra (Azure AD) external login providers — contains client secrets, stays server-side |
WhiteLabelingOptions also exposes a computed NormalizedCompanyName (lower-cased, ö→o,
spaces→_) and two static factories:
WhiteLabelingOptions.None— an all-null instance.WhiteLabelingOptions.For(customerName, ...)— builds a full set of asset paths under/_content/Cargonerds.UI.Shared/{customer}/...(logo.svg, app-logo.svg, favicon.ico, bg.png, bg.mp4, css/styles.css, js/scripts.js) and a default copyright link. Theappsettings.rohlig.jsonvalues mirror exactly whatFor("rohlig", ...)would produce.
The ExternalApis, FooterLink, EntraLogin and MapTiler shapes live alongside the options in
WhiteLabeling/WhiteLabelingOptions.classes.cs.
Brand assets¶
Brand assets are served from the Cargonerds.UI.Shared Razor Class Library
(src/Cargonerds.UI.Shared/, Microsoft.NET.Sdk.Razor, SupportedPlatform browser) as static web
assets. Its only real payload is wwwroot: a generic logo.svg/favicon.* plus a per-customer
folder. The "rohlig" folder, for example, contains:
src/Cargonerds.UI.Shared/wwwroot/
├── logo.svg, favicon.svg, favicon.ico # generic fallbacks
├── js/svgHelper.js # used by the MVC global script bundle
└── rohlig/
├── favicon.ico
├── css/styles.css
├── js/scripts.js
└── images/ (logo.svg, app-logo.svg, bg.png, bg.mp4, globe.svg, ...)
These are served at /_content/Cargonerds.UI.Shared/... and referenced by
WhiteLabelingOptions.For(...) / WhiteLabelingOptions.ContentFor(...). The RCL is referenced by the
Blazor host, the Blazor client, the AuthServer and the Public web.
Branding providers¶
Each .NET UI replaces the ABP DefaultBrandingProvider with a Cargonerds variant, registered via
[Dependency(ReplaceServices = true)]:
src/Cargonerds.Blazor.Client/CargonerdsBrandingProvider.cssrc/Cargonerds.AuthServer/CargonerdsBrandingProvider.cssrc/Cargonerds.Web.Public/CargonerdsBrandingProvider.cs
All pull AppName and LogoUrl from WhiteLabelingOptions, falling back to the localized AppName
key when config is empty (the Blazor client also maps LogoReverseUrl to the same URL):
[Dependency(ReplaceServices = true)]
public class CargonerdsBrandingProvider(
IStringLocalizer<CargonerdsResource> localizer,
IOptions<WhiteLabelingOptions> options
) : DefaultBrandingProvider
{
public override string AppName =>
!string.IsNullOrEmpty(options.Value.AppName) ? options.Value.AppName : localizer["AppName"];
public override string? LogoUrl => options.Value.Logo;
public override string? LogoReverseUrl => LogoUrl;
}
Branding couples to localization
When WhiteLabelingOptions.AppName is empty, the displayed app name comes from
localizer["AppName"] and therefore changes with the active culture. Set AppName in config
if you want a culture-independent brand name. See Localization.
Public vs. private config: the [PublicConfiguration] gate¶
WhiteLabelingOptions mixes brand data that is safe to expose (app name, logos) with secrets that
must never reach a browser (EntraLogins[].Secret, most ExternalApis keys). The split is driven by
the [PublicConfiguration] attribute
(modules/hub/src/Hub.Domain.Shared/WhiteLabeling/PublicConfigurationAttribute.cs):
RemoveNonPublicValues(obj)reflectively nulls out every property not marked[PublicConfiguration](recursing into nested public types).GetPublicConfigurationPropertyPaths(type)returns the colon-delimited config paths that are public, used to filter a flat configuration tree.
Two code paths surface white-labeling to clients, and both apply the gate:
- API application-config —
src/Cargonerds.HttpApi.Host/CargonerdsAppConfigContributor.csis anIApplicationConfigurationContributorthat callsRemoveNonPublicValues(whitelabel)thenSetProperty("WhiteLabeling", whitelabel)(alongsideAppVersion,Environment,BuildDate). This is part of the ABP application-configuration endpoint the WASM client reads at startup. - Blazor host
/appsettings.jsonmerge —src/Cargonerds.Blazor/AppSettingsHandler.csmerges the[PublicConfiguration]subset ofWhiteLabelingOptionsinto the runtimeappsettings.jsonserved to the WASM client, usingGetPublicConfigurationPropertyPathsplus a:\d+regex to match array-indexed paths. (The same handler also merges the Aspire-injectedClientConfigurationsection — see Blazor WebAssembly.)
Adding a sensitive field without omitting the attribute leaks it
EntraLogins deliberately has no [PublicConfiguration] attribute, so its client secrets are
stripped before reaching the browser. Within ExternalApis, only MapTiler is public; Google,
Algolia, PricingManager and Eadapter keys are server-side. If you add a new property and forget to
decorate it, it is silently dropped from the client; if you decorate a secret, you expose it
client-side. Note that appsettings.rohlig.json as checked in contains real-looking Entra,
Google, Algolia, PricingManager, Eadapter and MapTiler keys — those are secrets that travel with
the repo regardless of the runtime gate.
LeptonX theme (.NET hosts)¶
LeptonX is configured per host with different defaults. There are two flavours: the MVC theme (AuthServer, Public web) and the Blazor theme (admin client + its WASM-bundling host).
| Host | Theme module | DefaultStyle |
Layout |
|---|---|---|---|
| AuthServer | AbpAspNetCoreMvcUiLeptonXThemeModule |
Light |
MVC default |
| Web.Public | AbpAspNetCoreMvcUiLeptonXThemeModule |
System |
LeptonXMvcLayouts.TopMenu |
| Blazor (client) | AbpAspNetCoreComponentsWebAssemblyLeptonXThemeModule |
Light |
LeptonXBlazorLayouts.SideMenu |
Blazor admin (client + host)¶
Theme configuration is split across the two Blazor projects (see Blazor WebAssembly for the full host/client topology):
-
Client —
CargonerdsBlazorClientModule.ConfigureTheme()(src/Cargonerds.Blazor.Client/CargonerdsBlazorClientModule.cs):Configure<LeptonXThemeOptions>(options => { options.DefaultStyle = LeptonXStyleNames.Light; }); Configure<LeptonXThemeBlazorOptions>(options => { // When Layout is changed, the `options.Parameters["LeptonXTheme.Layout"]` // in CargonerdsBlazorModule.cs should be updated accordingly. options.Layout = LeptonXBlazorLayouts.SideMenu; });The same method also registers the white-labeling layout hook via
options.AddWhiteLabelingHook()(see Layout hooks & footer). -
Host —
CargonerdsBlazorModule.ConfigureServices()(src/Cargonerds.Blazor/CargonerdsBlazorModule.cs) sets the matching bundling parameter and wires the global style/script bundle contributors:Configure<AbpBundlingOptions>(options => { var globalStyles = options.StyleBundles.Get(BlazorWebAssemblyStandardBundles.Styles.Global); globalStyles.AddContributors(typeof(CargonerdsStyleBundleContributor)); var globalScripts = options.ScriptBundles.Get(BlazorWebAssemblyStandardBundles.Scripts.Global); globalScripts.AddContributors(typeof(CargonerdsScriptBundleContributor)); options.Parameters["LeptonXTheme.Layout"] = "side-menu"; // side-menu or top-menu });CargonerdsStyleBundleContributoraddsmain.css(minified);CargonerdsScriptBundleContributoris currently an empty bundle.
Two layout settings must stay in sync
LeptonXTheme.Layout (host bundling parameter, the string "side-menu") and
LeptonXThemeBlazorOptions.Layout (client, LeptonXBlazorLayouts.SideMenu) are set in two
different files. A comment in ConfigureTheme() explicitly warns to update both together — set
one without the other and the rendered layout and the bundled CSS disagree.
AuthServer & Public web (MVC)¶
Both MVC hosts inject custom CSS/JS into the LeptonX global bundle via AbpBundlingOptions
(Bundling & minification).
The AuthServer (src/Cargonerds.AuthServer/CargonerdsAuthServerModule.cs) also prepends
svgHelper.js from the shared RCL:
Configure<AbpBundlingOptions>(options =>
{
options.StyleBundles.Configure(LeptonXThemeBundles.Styles.Global,
b => b.AddFiles("/global-styles.css"));
options.ScriptBundles.Configure(LeptonXThemeBundles.Scripts.Global,
b =>
{
b.AddFiles("/_content/Cargonerds.UI.Shared/js/svgHelper.js");
b.AddFiles("/global-scripts.js");
});
});
The Public web (src/Cargonerds.Web.Public/CargonerdsWebPublicModule.cs) does the same with
/global-styles.css + /global-scripts.js, and selects the top-menu layout:
Configure<LeptonXThemeOptions>(o => o.DefaultStyle = LeptonXStyleNames.System);
Configure<LeptonXThemeMvcOptions>(o => o.ApplicationLayout = LeptonXMvcLayouts.TopMenu);
The AuthServer additionally overrides ABP Account texts via an AccountOverrides virtual-JSON folder
(see Localization).
MudBlazor alignment¶
The admin client uses MudBlazor (added via AddMudServicesWithExtensions(...)) only because
MudBlazor.Extensions powers the file-preview dialogs. To keep those Mud components from clashing
with LeptonX, MudLeptonTheme (src/Cargonerds.Blazor.Client/MudLeptonTheme.cs) reproduces LeptonX's
CSS variables as a MudTheme — semantic colors, border radius (0.5rem = --lpx-radius) and
typography (Inter, 0.875rem). The class doc-comment records the exact source CSS files
(Volo.Abp.LeptonXTheme/.../wwwroot/side-menu/css/{light,dark,dim}.css).
Shared LeptonX semantic colors (identical across all LeptonX styles — only background/surface/text differ between light and dark):
| Token | Value |
|---|---|
| Primary | #355dff |
| Secondary | #6c5dd3 |
| Success | #4fbf67 |
| Info | #438aa7 |
| Warning | #ff9f38 |
| Danger | #c00d49 |
public static MudTheme Create() =>
new()
{
PaletteLight = BuildLightPalette(),
PaletteDark = BuildDarkPalette(),
LayoutProperties = new LayoutProperties { DefaultBorderRadius = "0.5rem" }, // --lpx-radius
Typography = new Typography
{
Default = new DefaultTypography
{
FontFamily = ["Inter", "sans-serif"], // --bs-body-font-family
FontSize = "0.875rem", // --bs-body-font-size
},
},
};
The Mud palette is a manual copy of LeptonX variables
MudLeptonTheme hardcodes hex values lifted from LeptonX CSS. If an ABP upgrade changes the
LeptonX palette, the Mud theme silently drifts and must be re-synced by hand from the source files
named in the class doc-comment.
Light / dark bridge¶
LeptonX owns the light/dark/dim setting (cookie-backed). MudBlazor needs to follow it so dialogs match the rest of the shell. The bridge has two halves:
-
src/Cargonerds.Blazor/Components/App.razordefines a JS shim that forwards LeptonX's native DOM event into .NET: -
src/Cargonerds.Blazor.Client/Routes.razorreads the initial style fromILeptonXStyleProvider, subscribes to the event on first render, and flipsMudThemeProvider'sIsDarkModewhenever LeptonX switches:private static readonly MudTheme _theme = MudLeptonTheme.Create(); protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { try { var style = await StyleProvider.GetSelectedStyleAsync(); _isDarkMode = IsDarkStyle(style); StateHasChanged(); } catch { /* cookie not yet available */ } _dotNetRef = DotNetObjectReference.Create(this); await JS.InvokeVoidAsync("subscribeToLeptonXTheme", _dotNetRef); } } private static bool IsDarkStyle(string style) => style is LeptonXStyleNames.Dark or LeptonXStyleNames.Dim;<MudThemeProvider Theme="_theme" @bind-IsDarkMode="_isDarkMode" />then re-renders in the right mode. (BothDarkandDimLeptonX styles count as "dark" for MudBlazor.)
Layout hooks & footer¶
White-labeling is applied to the running Blazor shell three ways:
- Branding provider —
CargonerdsBrandingProvider(above). - Footer —
src/Cargonerds.Blazor.Client/Components/Layout/LeptonXFooter.razorinherits ABP'sFooterand replaces it ([ExposeServices(typeof(Footer))]+[Dependency(ReplaceServices = true)]). It renders the assembly version, the year andWhiteLabelingOptions.CopyrightLinks/FooterLinks. -
Layout hook —
ApplyWhiteLabeling(src/Cargonerds.Blazor.Client/Components/ApplyWhiteLabeling.razor.cs) is registered throughAbpLayoutHookOptionsatLayoutHooks.Body.FirstforStandardLayouts.Applicationby the extensionAbpLayoutHookOptionsExtensions.AddWhiteLabelingHook(). After first render it sets the favicon as the--lpx-logo-iconCSS variable on:root:
App.razor itself also reads IOptionsMonitor<WhiteLabelingOptions> for the page <title>
(CompanyName | AppName), the <link rel="icon">, and the custom loader screen (logo, background
image/video).
On the MVC side, the AuthServer registers the same hook (AddWhiteLabelingHook() in
CargonerdsAuthServerModule) and renders a WhiteLabeling view-component
(src/Cargonerds.AuthServer/Pages/Shared/Components/WhiteLabeling/Default.cshtml). That component
serializes the (already public-filtered) options into window.WhiteLabelingOptions, sets the
login-area background image/video and favicon as LeptonX CSS variables (--lpx-theme-light-bg,
--lpx-theme-dim-bg, --lpx-theme-dark-bg, --lpx-logo-icon), and dynamically injects
AdditionalStyleSheetFiles / AdditionalJavaScriptFiles. It can also inject an admin-configured
custom script (gated by the CustomScript setting — see below).
AdditionalStyleSheetFiles / AdditionalJavaScriptFiles are consumed on the MVC side
The injection of these arrays happens in the AuthServer WhiteLabeling view-component
(_loadCss / _loadScripts in Default.cshtml). They are part of the public config, but the
Blazor client's ApplyWhiteLabeling hook only applies the favicon variable — it does not load
these arrays.
Custom script setting¶
The admin client adds a "CustomScript" settings group through an
ISettingComponentContributor
(src/Cargonerds.Blazor.Client/Settings/CustomScriptSettingComponentContributor.cs), gated by the
CargonerdsPermissions.Settings.CustomScript permission. The stored value is what the AuthServer's
white-labeling view-component injects (e.g. analytics snippets) when CustomScriptEnabled is true.
Realtime (Next.js) brand palette¶
The realtime frontend is a separate Tailwind CSS v4 app and does not use LeptonX. Its theme is
declared in frontend/realtime/src/app/globals.css via Tailwind's @theme block. Core brand colors:
| Token | Value | Notes |
|---|---|---|
--color-brand-100 |
#002c61 |
Brand navy (primary) |
--color-brand-125 |
#00152f |
Darker navy |
--color-brand-60 |
#006cab |
Mid blue |
--color-brand-20 |
#00b6e8 |
Brand cyan |
--color-green-100 |
#28c25b |
Success |
--color-red-100 |
#dc2626 |
Error |
It also defines a neutral grey-10 … grey-150 scale, a typographic scale (--text-xs … --text-7xl,
several with explicit line-heights) and custom breakpoints (sm: 501px, md: 1001px, lg: 1401px,
xl: 1513px). The file pulls components in from the ui-library workspace via
@source "../../../ui-library/**/*.{js,ts,jsx,tsx}". See
Realtime (Next.js) Frontend for the rest of that app.
Brand colors are duplicated, not shared
The Tailwind brand palette (#002c61 etc.) and the LeptonX/Mud palette (#355dff etc.) are
different and maintained independently. There is no build step that derives one from the other
— changing brand colors means editing both globals.css and (for the .NET side) LeptonX CSS plus
MudLeptonTheme.
Switching tenant / brand locally¶
There are two equivalent levers:
- Point the
WHITE_LABEL_SETTINGS_PATHenv var at a differentappsettings.<tenant>.json, or - Change the file referenced by
WithWhiteLabelSettingsPath()insrc/Cargonerds.AppHost/Extensions/ResourceBuilderExtensions.cs(it resolvesappsettings.rohlig.jsonin run mode and passes it to AuthServer, the API host and the Blazor host).
The new file's WhiteLabelingOptions section then flows everywhere automatically: MVC hosts read it
directly; the WASM client receives the public subset through the API application-config and the
Blazor host's /appsettings.json merge. See the
Configuration Reference for the full recipe.
Static vs. runtime config in the WASM client
src/Cargonerds.Blazor.Client/wwwroot/appsettings.json holds only local-dev fallbacks. In an
Aspire/deployed run the effective white-labeling (and auth/API URLs) come from the server-merged
/appsettings.json and the API application-config. Editing only the static file will not change a
running Aspire session. See Blazor WebAssembly.
Gotchas¶
- Two layout settings.
LeptonXTheme.Layout(Blazor host bundling) andLeptonXThemeBlazorOptions.Layout(client) must agree — set in two files, with an in-code warning. - Mud palette drift.
MudLeptonThemeis a hand-copied snapshot of LeptonX CSS variables; re-sync after an ABP upgrade. - Branding ↔ localization coupling. Empty
AppNamefalls back tolocalizer["AppName"], so the brand name follows the culture. [PublicConfiguration]gate. Forgetting the attribute drops a field from the client; adding it to a secret leaks it.EntraLoginsand mostExternalApiskeys are intentionally server-side.- Committed secrets.
appsettings.rohlig.jsoncontains real-looking Entra/Google/Algolia/ PricingManager/Eadapter/MapTiler keys in source control — independent of the runtime gate, they should be rotated/moved to secrets. - Two palettes, no shared source. Tailwind brand colors and LeptonX colors are maintained separately.
- External theme assets.
App.razorloads MapLibre and Roboto (MudBlazor) from CDNs (unpkg, Google Fonts) — these are external runtime dependencies of the admin shell.
Related pages¶
- Blazor WebAssembly — host/client topology, render mode,
AppSettingsHandler, MudBlazor. - Localization —
CargonerdsResource, theAppNamefallback key, AccountOverrides. - Public Web — the MVC LeptonX top-menu app.
- Realtime (Next.js) Frontend — the Tailwind app and
ui-library. - .NET Aspire Integration —
WithWhiteLabelSettingsPath,{admin}/{auth}/{api}token substitution. - Configuration Reference / appsettings Reference
— the
WhiteLabelingOptionssection and[PublicConfiguration]exposure model. - ABP Framework Patterns — branding provider, layout hooks, service replacement, settings/bundling contributors.