Architecture Decision Record
ADR-028: Day/Dark Theme Mode
Status
Accepted (2026-06-27; revised 2026-07-15; revised 2026-08-31).
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 (see Decision 3).
Decision
Bind the existing theme. The shared
MainLayoutrenders a single<MmcaThemeProviders />component (MMCA.Common.UI/Layout/MainLayout.razor:14), 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:12), a two-way binding to that component's own_isDarkModefield (MmcaThemeProviders.razor:25); no@refis used.Themeis aMudThemeparameter whose default is the already-completeMMCATheme.Instance(MmcaThemeProviders.razor:23), 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:28) 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. 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.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:8) plus<CultureSwitcher />and<ThemeToggle />in its ownMudAppBar(MainLayout.razor:15-16), 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#↔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. 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. - 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:6-7) 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.
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 host-wiring fitness assertion).