Skip to content

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.&lt;tenant&gt;.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&lt;WhiteLabelingOptions&gt;().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. The appsettings.rohlig.json values mirror exactly what For("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.cs
  • src/Cargonerds.AuthServer/CargonerdsBrandingProvider.cs
  • src/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:

  1. API application-configsrc/Cargonerds.HttpApi.Host/CargonerdsAppConfigContributor.cs is an IApplicationConfigurationContributor that calls RemoveNonPublicValues(whitelabel) then SetProperty("WhiteLabeling", whitelabel) (alongside AppVersion, Environment, BuildDate). This is part of the ABP application-configuration endpoint the WASM client reads at startup.
  2. Blazor host /appsettings.json mergesrc/Cargonerds.Blazor/AppSettingsHandler.cs merges the [PublicConfiguration] subset of WhiteLabelingOptions into the runtime appsettings.json served to the WASM client, using GetPublicConfigurationPropertyPaths plus a :\d+ regex to match array-indexed paths. (The same handler also merges the Aspire-injected ClientConfiguration section — 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):

  • ClientCargonerdsBlazorClientModule.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).

  • HostCargonerdsBlazorModule.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
    });
    

    CargonerdsStyleBundleContributor adds main.css (minified); CargonerdsScriptBundleContributor is 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:

  1. src/Cargonerds.Blazor/Components/App.razor defines a JS shim that forwards LeptonX's native DOM event into .NET:

    window.subscribeToLeptonXTheme = function (dotNetRef) {
        document.body.addEventListener('lpx:appearance-setting', function (e) {
            var theme = e.detail && e.detail.theme;
            if (theme) dotNetRef.invokeMethodAsync('OnLeptonXThemeChanged', theme);
        });
    };
    
  2. src/Cargonerds.Blazor.Client/Routes.razor reads the initial style from ILeptonXStyleProvider, subscribes to the event on first render, and flips MudThemeProvider's IsDarkMode whenever 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. (Both Dark and Dim LeptonX styles count as "dark" for MudBlazor.)

White-labeling is applied to the running Blazor shell three ways:

  1. Branding providerCargonerdsBrandingProvider (above).
  2. Footersrc/Cargonerds.Blazor.Client/Components/Layout/LeptonXFooter.razor inherits ABP's Footer and replaces it ([ExposeServices(typeof(Footer))] + [Dependency(ReplaceServices = true)]). It renders the assembly version, the year and WhiteLabelingOptions.CopyrightLinks / FooterLinks.
  3. Layout hookApplyWhiteLabeling (src/Cargonerds.Blazor.Client/Components/ApplyWhiteLabeling.razor.cs) is registered through AbpLayoutHookOptions at LayoutHooks.Body.First for StandardLayouts.Application by the extension AbpLayoutHookOptionsExtensions.AddWhiteLabelingHook(). After first render it sets the favicon as the --lpx-logo-icon CSS variable on :root:

    public static AbpLayoutHookOptions AddWhiteLabelingHook(this AbpLayoutHookOptions options) =>
        options.Add(LayoutHooks.Body.First, typeof(ApplyWhiteLabeling), StandardLayouts.Application);
    

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_PATH env var at a different appsettings.<tenant>.json, or
  • Change the file referenced by WithWhiteLabelSettingsPath() in src/Cargonerds.AppHost/Extensions/ResourceBuilderExtensions.cs (it resolves appsettings.rohlig.json in 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) and LeptonXThemeBlazorOptions.Layout (client) must agree — set in two files, with an in-code warning.
  • Mud palette drift. MudLeptonTheme is a hand-copied snapshot of LeptonX CSS variables; re-sync after an ABP upgrade.
  • Branding ↔ localization coupling. Empty AppName falls back to localizer["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. EntraLogins and most ExternalApis keys are intentionally server-side.
  • Committed secrets. appsettings.rohlig.json contains 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.razor loads MapLibre and Roboto (MudBlazor) from CDNs (unpkg, Google Fonts) — these are external runtime dependencies of the admin shell.