Architecture Decision Record
ADR-028: Day/Dark Theme Mode
Status
Accepted (2026-06-27; revised 2026-07-15; revised 2026-08-31; revised 2026-10-01).
Revised 2026-10-06: the first-paint flash is scoped to web heads and the MAUI first-frame mode source is recorded.
Revised 2026-10-19: the MAUI host pre-paint script ships from MMCA.Common.UI (theme-prepaint.js, v1.246.0) and both MAUI heads load it.
Context
MMCATheme (MMCA.Common.UI/Theme/MMCATheme.cs) has always defined a complete, brand-tuned PaletteDark
alongside PaletteLight, but MudThemeProvider was hard-wired to light: no @ref, no IsDarkMode
binding, no toggle, no persistence. Dark mode was designed and then never connected. This ADR connects it.
The mechanics are the same ones ADR-027 solves for locale: a Blazor InteractiveAuto app must agree on the
theme across SSR prerender, the InteractiveServer circuit, and the InteractiveWebAssembly client to avoid a
flash of the wrong theme (FOUC) on load. So the theme toggle reuses the i18n persistence machinery (cookie +
localStorage + profile) rather than inventing a parallel one; the matching no-flash SSR bootstrap is the
intended end state but is not yet wired for theme on web heads (see Decision 3).
Decision
Bind the existing theme. The shared
MainLayoutrenders a single<MmcaThemeProviders />component (MMCA.Common.UI/Layout/MainLayout.razor:16), which owns the four Mud providers plus the Day/Dark lifecycle in one place. Inside that componentMudThemeProvideris bound withTheme="@Theme"and@bind-IsDarkMode(MMCA.Common.UI/Theme/MmcaThemeProviders.razor:14), a two-way binding to that component's own_isDarkModefield (MmcaThemeProviders.razor:38); no@refis used.Themeis aMudThemeparameter whose default is the already-completeMMCATheme.Instance(MmcaThemeProviders.razor:36), so a consuming app that needs its own brand passes a derivedMudThemeinstead of duplicating the provider block. The layout no longer holds the provider markup or the_isDarkModefield itself. No new palette work.A
ThemeService(MMCA.Common.UI) owns the preference, registered inAddUIShared. It holds the current mode, reads/writes a non-HttpOnly cookie + localStorage, and raises a change event thatMmcaThemeProviders(MmcaThemeProviders.razor:52) and everyThemeToggle(ThemeToggle.razor:16) subscribe to, so the shared providers component and the app-bar toggle stay in sync; the layout itself subscribes to nothing. First-visit default is the OSprefers-color-scheme, read via a small JS interop call (theme.jssystemPrefersDark()->window.matchMedia('(prefers-color-scheme: dark)')), used only when no cookie/profile value exists.Theme is restored from the cookie/localStorage after first render: the no-flash SSR bootstrap is outstanding.
ThemeService.InitializeAsyncreads the persisted value via JS interop fromOnAfterRenderAsync(firstRender)and deliberately does not run during SSR prerender, so the boundIsDarkModeis corrected just after hydration. The cookie-as-single-source-of-truth persistence of ADR-027 is reused, but the server-side prerender read that makes locale flash-free (adata-themeattribute / inline<head>script emitted from the cookie before Blazor hydrates) is not yet wired for theme. On web heads a brief wrong-theme flash on first paint is therefore currently possible; emitting the theme server-side at prerender to close it is tracked as follow-up. MAUI heads do not wait for JS:MMCA.Common.UI.MauiregistersIInitialThemeModeSourceasMauiInitialThemeModeSource(MMCA.Common.UI.Maui/DependencyInjection.cs:68), andMmcaThemeProviders.OnInitializedseeds_isDarkModefrom it before subscribing (MmcaThemeProviders.razor:50), so the first frame paints in the stored mode. Web heads register no source and keep the post-render JS path. Before Blazor renders at all, the host page itself is seeded by a framework-shipped pre-paint script:MMCA.Common.UI/wwwroot/theme-prepaint.js, a classic synchronous script that stamps<html data-mmca-theme="dark|light">from themmca_themecookie, then localStorage, then the OS scheme (theme-prepaint.js:14-43). The matching rules in Common'sapp.css(MMCA.Common.UI/wwwroot/app.css:100-126) paint the host surface in that mode untilMmcaThemeProvidersmarks the page interactive (data-mmca-interactive,MmcaThemeProviders.razor:77-99); every selector requiresdata-mmca-theme, so web heads, which do not load the script, are untouched. Both MAUI heads load it as the first script in<head>(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/wwwroot/index.html:8,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/wwwroot/index.html:15). Store keeps one inline script of its own beside it, the Android status-bar height fallback (index.html:16-23), which is not a theme concern.The toggle ships in the shared
MainLayout, next to the i18n culture switcher, in the app-barappbar-icon-actionsslot, so every consumer gets both controls without per-host wiring.The choice is persisted to the Identity profile (
User.PreferredTheme), in the same migration and with the same login-reconciliation rule asUser.PreferredCulture(ADR-027): DB is the cross-device source of truth, the cookie is the runtime channel; on login the cookie is set from the profile, an authenticated toggle writes both, anonymous users get cookie/localStorage only.Helpdesk is brought into line. Its host's custom
MainLayoutused a bare<MudThemeProvider />(not evenMMCATheme); it now renders the framework's<MmcaThemeProviders />(MMCA.Helpdesk.UI.Web/Components/Layout/MainLayout.razor:10) plus<CultureSwitcher />and<ThemeToggle />in its ownMudAppBar(MainLayout.razor:27-28), so the theme, the boundIsDarkModeand the whole lifecycle come from the shared component rather than being restated in the host. As anInteractiveServer-only host it has no WASM boundary, but it still reads the cookie for consistency.
Rationale
- Reusing the i18n cookie/profile machinery means one persistence model for both user preferences, instead of two subtly different ones. Theme and locale are the same shape of problem, so the no-flash SSR bootstrap built for locale is the template theme will follow when it is wired.
- The palette already existed, so the cost is wiring + persistence, not design, and
BrandColorTokenTestsalready guards the C# to CSS token sync, so the dark surfaces stay on-brand. - Defaulting to the OS preference respects the user's system setting on first visit while letting an explicit choice win and follow them across devices.
Trade-offs
- The same FOUC hazard as locale is not yet closed for theme on web heads. The SSR
data-theme/inline-script read is unimplemented (Decision 3), so the first paint can briefly flash the wrong theme before the post-render JS interop corrects it; there is no free no-flash for InteractiveAuto. MAUI heads avoid it throughIInitialThemeModeSource(Decision 3). - Helpdesk's custom layout is still wired separately because it does not inherit Common's
MainLayout, but the obligation is now two component tags (<MmcaThemeProviders />plus<ThemeToggle />) rather than a provider block and its lifecycle: the layout's own comment (MMCA.Helpdesk.UI.Web/Components/Layout/MainLayout.razor:8-9) records that the four Mud providers and the Day/Dark lifecycle belong toMmcaThemeProvidersand that the layout carries only Helpdesk chrome. Future hosts that fork the layout inherit that same two-tag obligation. - Per-user persistence adds a column to the Identity
User(folded into the ADR-027 migration, so no extra migration), and a profile-edit surface.
Revision (2026-10-01)
Anchor refresh only: no decision or rationale changed. The snackbar live-region wrapper above the
@code block moved the MmcaThemeProviders members, so the Theme parameter, the _isDarkMode field
and the OnChange subscription are re-cited at MmcaThemeProviders.razor:34, :36 and :39. Helpdesk's
MainLayout gained a skip-nav link and app-bar accessibility comments, so its ownership comment,
<MmcaThemeProviders /> and the <CultureSwitcher /> / <ThemeToggle /> pair are re-cited at
MainLayout.razor:8-9, :10 and :27-28.
Revision (2026-10-06)
- Decision 3 and Trade-offs scope the first-paint wrong-theme flash to web heads and record the MAUI
path:
IInitialThemeModeSourceregistered byMMCA.Common.UI.Maui/DependencyInjection.cs:68and read inMmcaThemeProviders.razor:47, plus Store's MAUI host pre-paint script (MMCA.Store.UI/wwwroot/index.html:16-43). - Related no longer credits ADR-015 with a theme host-wiring assertion: no fitness test asserts theme wiring today.
- Two non-ASCII arrows in Decision 2 and Rationale replaced with plain text.
- Anchors re-verified against current source: shared
MainLayout.razor:16;MmcaThemeProviders.razor:14(provider),:36(Theme),:38(_isDarkMode) and:52(OnChange), superseding the:34,:36,:39recorded on 2026-10-01. Helpdesk anchors (:8-9,:10,:27-28) andThemeToggle.razor:16unchanged.
Revision (2026-10-19)
- Decision 3 now describes the MAUI host pre-paint as framework machinery. Store's host-local inline
theme script and its
app.cssrules moved into MMCA.Common.UI v1.246.0 astheme-prepaint.jsplus thedata-mmca-themerules in the sharedapp.css(:100-126), and ADC's MAUI head, which had no pre-paint seed, adopts the same script (MMCA.ADC.UI/wwwroot/index.html:8), so a dark-mode cold launch no longer flashes white on either head. Store keeps only its status-bar fallback inline. - The
MmcaThemeProvidersread ofIInitialThemeModeSourceis re-cited atMmcaThemeProviders.razor:50(was:47); the registration atMMCA.Common.UI.Maui/DependencyInjection.cs:68is unchanged.
Related
ADR-027 (shares the cookie source-of-truth and the User preference migration,
and is the model for the theme no-flash SSR bootstrap that is not yet wired),
ADR-022 (the SSR cookie-read pattern),
ADR-015 (the fitness-function framework; no theme host-wiring
assertion exists in it today).