Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/theme-first-paint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Theme first-paint contract

`RatelDeskTheme` is the source of truth for both MudBlazor's runtime theme and the head-resident prepaint variables emitted by `RatelDeskPrepaintTheme`. The synchronous bootstrap normalizes the existing `helpdesk.theme.preference` value, resolves System against `prefers-color-scheme`, and sets the document attribute plus `color-scheme` before stylesheet and body parsing can paint visible application content.

The prepaint selectors retain higher specificity than MudBlazor's initial `:root` variables, so the browser does not expose the provider's server-side light default while the interactive provider reads browser state. Runtime ownership then remains with `ThemePreferenceProvider`; it adopts the normalized preference, applies user selections safely even if storage is blocked, and retains System-mode OS change handling. The Playwright first-paint spec pauses only the Blazor runtime and observes real prerendered body/card/text colors before and after runtime release.
71 changes: 66 additions & 5 deletions src/HelpDesk.NewWeb/Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,75 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<base href="/" />
<style id="helpdesk-prepaint-theme">@((MarkupString)RatelDeskPrepaintTheme.Css)</style>
<script>
(function () {
const key = "helpdesk.theme.preference";
const stored = window.localStorage.getItem(key);
const isDark = stored === "dark" || (stored !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.helpdeskTheme = isDark ? "dark" : "light";
const storageKey = "helpdesk.theme.preference";

const normalize = (value) => typeof value === "string" ? value.trim().toLowerCase() : "system";
const storage = () => {
try {
return window.localStorage;
} catch {
return null;
}
};
const get = () => {
const availableStorage = storage();
if (!availableStorage) {
return null;
}

try {
return availableStorage.getItem(storageKey);
} catch {
return null;
}
};
const systemTheme = () => {
try {
return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ? "dark" : "light";
} catch {
return "light";
}
};
const resolve = (value) => {
const preference = normalize(value);
return preference === "dark" || preference === "light" ? preference : systemTheme();
};
const apply = (value) => {
const theme = value === true || value === "dark" ? "dark" : "light";
document.documentElement.dataset.helpdeskTheme = theme;
document.documentElement.style.colorScheme = theme;
return theme;
};

window.helpdeskThemePreference = {
get: () => normalize(get()),
set: (value) => {
const preference = normalize(value);
const availableStorage = storage();
if (!availableStorage) {
return;
}

try {
if (preference === "system") {
availableStorage.removeItem(storageKey);
} else {
availableStorage.setItem(storageKey, preference);
}
} catch {
// The in-memory selection is still applied by the caller.
}
},
apply,
resolve
};

apply(resolve(get()));
}());
</script>
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
Expand Down Expand Up @@ -40,7 +102,6 @@
<script src=@Assets["_content/MudBlazor/MudBlazor.min.js"]></script>
<script src=@Assets["_content/Extensions.MudBlazor.StaticInput/NavigationObserver.js"]></script>
<script src="js/global-search-hotkeys.js"></script>
<script src="js/theme-preference.js"></script>
<script>
window.scrollToNotificationRow = function (id) {
setTimeout(function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
private bool isDarkMode;
private bool systemDarkMode;
private bool initialized;
private long selectionVersion;

public ThemePreferenceMode Mode => mode;
public bool IsDarkMode => isDarkMode;
Expand All @@ -45,8 +46,16 @@
return;
}

mode = ParseMode(await JS.InvokeAsync<string?>("helpdeskThemePreference.get"));
systemDarkMode = await themeProvider.GetSystemDarkModeAsync();
var initializationVersion = selectionVersion;
var persistedMode = ParseMode(await JS.InvokeAsync<string?>("helpdeskThemePreference.get"));
var initialSystemDarkMode = await themeProvider.GetSystemDarkModeAsync();

if (initializationVersion == selectionVersion)
{
mode = persistedMode;
}

systemDarkMode = initialSystemDarkMode;
isDarkMode = ResolveDarkMode();
initialized = true;

Expand All @@ -57,13 +66,19 @@

public async Task SetModeAsync(ThemePreferenceMode selectedMode)
{
var updateVersion = ++selectionVersion;
mode = selectedMode;

if (mode is ThemePreferenceMode.System && themeProvider is not null)
{
systemDarkMode = await themeProvider.GetSystemDarkModeAsync();
}

if (updateVersion != selectionVersion)
{
return;
}

isDarkMode = ResolveDarkMode();
await JS.InvokeVoidAsync("helpdeskThemePreference.set", ToStorageValue(mode));
await ApplyDocumentThemeAsync();
Expand Down
106 changes: 106 additions & 0 deletions src/HelpDesk.NewWeb/Themes/RatelDeskPrepaintTheme.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using System.Globalization;
using System.Text;
using MudBlazor;
using MudBlazor.Utilities;

namespace HelpDesk.NewWeb.Themes;

/// <summary>
/// Emits the palette variables needed before the interactive MudBlazor provider can run.
/// The values are read from <see cref="RatelDeskTheme"/>, which remains the sole palette source.
/// </summary>
public static class RatelDeskPrepaintTheme
{
private static readonly RatelDeskTheme Theme = new();

public static string Css { get; } = BuildCss();

private static string BuildCss()
{
var css = new StringBuilder();
AppendPalette(css, "html[data-helpdesk-theme=\"light\"]", Theme.PaletteLight, "light");
AppendPalette(css, "html[data-helpdesk-theme=\"dark\"]", Theme.PaletteDark, "dark");

// JavaScript cannot read a stored override when it is disabled. System is the safe fallback.
css.Append("@media (prefers-color-scheme: dark){");
AppendPalette(css, "html:not([data-helpdesk-theme])", Theme.PaletteDark, "dark");
css.Append('}');
css.Append("@media (prefers-color-scheme: light){");
AppendPalette(css, "html:not([data-helpdesk-theme])", Theme.PaletteLight, "light");
css.Append('}');
return css.ToString();
}

private static void AppendPalette(StringBuilder css, string selector, Palette palette, string colorScheme)
{
css.Append(selector).Append("{color-scheme:").Append(colorScheme).Append(";background:").Append(Color(palette.Background)).Append(';');

AppendColor(css, "black", palette.Black);
AppendColor(css, "white", palette.White);
AppendColorFamily(css, "primary", palette.Primary, palette.PrimaryContrastText, palette.PrimaryDarken, palette.PrimaryLighten, palette.HoverOpacity);
AppendColorFamily(css, "secondary", palette.Secondary, palette.SecondaryContrastText, palette.SecondaryDarken, palette.SecondaryLighten, palette.HoverOpacity);
AppendColorFamily(css, "tertiary", palette.Tertiary, palette.TertiaryContrastText, palette.TertiaryDarken, palette.TertiaryLighten, palette.HoverOpacity);
AppendColorFamily(css, "info", palette.Info, palette.InfoContrastText, palette.InfoDarken, palette.InfoLighten, palette.HoverOpacity);
AppendColorFamily(css, "success", palette.Success, palette.SuccessContrastText, palette.SuccessDarken, palette.SuccessLighten, palette.HoverOpacity);
AppendColorFamily(css, "warning", palette.Warning, palette.WarningContrastText, palette.WarningDarken, palette.WarningLighten, palette.HoverOpacity);
AppendColorFamily(css, "error", palette.Error, palette.ErrorContrastText, palette.ErrorDarken, palette.ErrorLighten, palette.HoverOpacity);
AppendColorFamily(css, "dark", palette.Dark, palette.DarkContrastText, palette.DarkDarken, palette.DarkLighten, palette.HoverOpacity);

AppendColor(css, "text-primary", palette.TextPrimary, includeRgb: true);
AppendColor(css, "text-secondary", palette.TextSecondary, includeRgb: true);
AppendColor(css, "text-disabled", palette.TextDisabled, includeRgb: true);
AppendColor(css, "action-default", palette.ActionDefault);
Append(css, "action-default-hover", palette.ActionDefault.SetAlpha(palette.HoverOpacity).ToString(MudColorOutputFormats.RGBA));
AppendColor(css, "action-disabled", palette.ActionDisabled);
AppendColor(css, "action-disabled-background", palette.ActionDisabledBackground);
AppendColor(css, "surface", palette.Surface, includeRgb: true);
AppendColor(css, "background", palette.Background);
AppendColor(css, "background-gray", palette.BackgroundGray);
AppendColor(css, "drawer-background", palette.DrawerBackground);
AppendColor(css, "drawer-text", palette.DrawerText);
AppendColor(css, "drawer-icon", palette.DrawerIcon);
AppendColor(css, "appbar-background", palette.AppbarBackground);
AppendColor(css, "appbar-text", palette.AppbarText);
AppendColor(css, "lines-default", palette.LinesDefault);
AppendColor(css, "lines-inputs", palette.LinesInputs);
AppendColor(css, "table-lines", palette.TableLines);
AppendColor(css, "table-striped", palette.TableStriped);
AppendColor(css, "table-hover", palette.TableHover);
AppendColor(css, "divider", palette.Divider, includeRgb: true);
AppendColor(css, "divider-light", palette.DividerLight);
AppendColor(css, "skeleton", palette.Skeleton);
AppendColor(css, "gray-default", palette.GrayDefault);
AppendColor(css, "gray-light", palette.GrayLight);
AppendColor(css, "gray-lighter", palette.GrayLighter);
AppendColor(css, "gray-dark", palette.GrayDark);
AppendColor(css, "gray-darker", palette.GrayDarker);
AppendColor(css, "overlay-dark", palette.OverlayDark);
AppendColor(css, "overlay-light", palette.OverlayLight);
Append(css, "border-opacity", palette.BorderOpacity.ToString(CultureInfo.InvariantCulture));
css.Append("--mud-ripple-color:var(--mud-palette-text-primary);");
css.Append('}');
}

private static void AppendColorFamily(StringBuilder css, string name, MudColor color, MudColor contrast, string darken, string lighten, double hoverOpacity)
{
AppendColor(css, name, color, includeRgb: true);
AppendColor(css, $"{name}-text", contrast);
Append(css, $"{name}-darken", darken);
Append(css, $"{name}-lighten", lighten);
Append(css, $"{name}-hover", color.SetAlpha(hoverOpacity).ToString(MudColorOutputFormats.RGBA));
}

private static void AppendColor(StringBuilder css, string name, MudColor color, bool includeRgb = false)
{
Append(css, name, Color(color));
if (includeRgb)
{
Append(css, $"{name}-rgb", color.ToString(MudColorOutputFormats.ColorElements));
}
}

private static void Append(StringBuilder css, string name, string value) =>
css.Append("--mud-palette-").Append(name).Append(':').Append(value).Append(';');

private static string Color(MudColor color) => color.ToString(MudColorOutputFormats.RGBA);
}
20 changes: 0 additions & 20 deletions src/HelpDesk.NewWeb/wwwroot/js/theme-preference.js

This file was deleted.

41 changes: 41 additions & 0 deletions tests/Helpdesk.Tests/NewWeb/RatelDeskPrepaintThemeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
extern alias NewWeb;

using NewWeb::HelpDesk.NewWeb.Themes;
using MudBlazor;
using MudBlazor.Utilities;

namespace Helpdesk.Tests.NewWeb;

public sealed class RatelDeskPrepaintThemeTests
{
[Fact]
public void Prepaint_css_uses_the_canonical_light_and_dark_palette_values()
{
var css = RatelDeskPrepaintTheme.Css;
var theme = new RatelDeskTheme();

Assert.Contains("html[data-helpdesk-theme=\"light\"]", css);
Assert.Contains($"--mud-palette-background:{Color(theme.PaletteLight.Background)};", css);
Assert.Contains($"--mud-palette-surface:{Color(theme.PaletteLight.Surface)};", css);
Assert.Contains($"--mud-palette-text-primary:{Color(theme.PaletteLight.TextPrimary)};", css);
Assert.Contains($"--mud-palette-appbar-background:{Color(theme.PaletteLight.AppbarBackground)};", css);
Assert.Contains($"--mud-palette-divider:{Color(theme.PaletteLight.Divider)};", css);

Assert.Contains("html[data-helpdesk-theme=\"dark\"]", css);
Assert.Contains($"--mud-palette-background:{Color(theme.PaletteDark.Background)};", css);
Assert.Contains($"--mud-palette-surface:{Color(theme.PaletteDark.Surface)};", css);
Assert.Contains($"--mud-palette-text-primary:{Color(theme.PaletteDark.TextPrimary)};", css);
Assert.Contains($"--mud-palette-appbar-background:{Color(theme.PaletteDark.AppbarBackground)};", css);
Assert.Contains($"--mud-palette-divider:{Color(theme.PaletteDark.Divider)};", css);
Assert.Contains($"--mud-palette-overlay-dark:{Color(theme.PaletteDark.OverlayDark)};", css);
}

[Fact]
public void Prepaint_css_has_a_system_fallback_when_javascript_is_unavailable()
{
Assert.Contains("@media (prefers-color-scheme: dark){html:not([data-helpdesk-theme])", RatelDeskPrepaintTheme.Css);
Assert.Contains("@media (prefers-color-scheme: light){html:not([data-helpdesk-theme])", RatelDeskPrepaintTheme.Css);
}

private static string Color(MudColor color) => color.ToString(MudColorOutputFormats.RGBA);
}
Loading
Loading