From 63cf774416fc4e50a34e8c08ec481a7cccb6f4c3 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 27 Jul 2026 11:56:48 -0500 Subject: [PATCH 1/6] Rebuild the app around one M3 Expressive shape, color and motion system Light mode had drifted well behind dark after the MD3 work: neighbouring neutral surfaces are only 1.05:1 apart there, so tone alone could not carry control visibility and toggle groups became close to unreadable. The fix is systemic rather than per-control. app.css now owns an M3 shape scale, a spatial/effects motion pair, and a rule that any control sitting on a tonal card takes a chromatic container: unselected toggle segments use the tonal-button pair (secondaryContainer on onSecondaryContainer, 7.25:1) and the selected segment is a filled primary pill (5.5:1 against its card, up from 1.05:1). A seed sweep in MaterialThemeTests locks those pairs so Material You wallpapers cannot break them. The sticky header no longer ends flush at the day pill. The pill is its own raised container, the header keeps 8dp under it, and the on-scroll cue is a soft lift plus an outlineVariant hairline that stays visible on a tone-6 dark surface where a black shadow does not. Also in this pass: the slider's active track was being painted over by the range input and never showed at all; number and text rows had no container and an empty one was invisible; MudRating drew the same icon for full and empty; grouped lists, cards and chart cards moved onto the shape scale; outlined boundaries moved from outlineVariant (1.46:1) to outline (3.86:1); charts, menus, dialogs and alerts took their colors from the palette instead of MudBlazor and ApexCharts defaults; the letterbox behind the WebView now follows the generated palette, so device colors reach it; and an OS theme change no longer overwrites the theme the user picked. Verified on an Android emulator in both modes, not from mockups. --- .../Data/PreferenceServiceTests.cs | 31 ++ JournalApp.Tests/MaterialThemeTests.cs | 49 +++ JournalApp/Components/DataPointView.razor | 3 +- JournalApp/Components/JaMessageBox.razor | 8 +- JournalApp/Data/MaterialTheme.cs | 21 +- JournalApp/Data/PreferenceService.cs | 42 +- JournalApp/MainPage.xaml | 1 + .../Pages/Calendar/CalendarDay.razor.css | 5 +- .../Pages/Calendar/CalendarMonth.razor.css | 22 +- .../Pages/ManageCategoriesPage.razor.css | 12 +- .../SafetyPlanning/SafetyPlanPage.razor.css | 12 +- JournalApp/Pages/SettingsPage.razor.css | 12 +- JournalApp/Pages/Trends/TrendsPage.razor.css | 3 +- .../Pages/Worksheets/WorksheetsPage.razor.css | 17 +- JournalApp/wwwroot/app.css | 361 +++++++++++++++--- JournalApp/wwwroot/index.html | 1 - 16 files changed, 484 insertions(+), 116 deletions(-) diff --git a/JournalApp.Tests/Data/PreferenceServiceTests.cs b/JournalApp.Tests/Data/PreferenceServiceTests.cs index dbae187..269d40a 100644 --- a/JournalApp.Tests/Data/PreferenceServiceTests.cs +++ b/JournalApp.Tests/Data/PreferenceServiceTests.cs @@ -43,6 +43,37 @@ public void SelectedAppTheme_WithInvalidStoredValue_FallsBackToUnspecified() result.Should().Be(AppTheme.Unspecified); } + [Fact] + public void HandleOsThemeChanged_KeepsTheUsersChoice() + { + // Arrange + var preferences = Services.GetService(); + var preferenceService = Services.GetService(); + preferenceService.SelectedAppTheme = AppTheme.Light; + + // Act + preferenceService.HandleOsThemeChanged(); + + // Assert + preferenceService.SelectedAppTheme.Should().Be(AppTheme.Light); + preferences.Get("theme", string.Empty).Should().Be(nameof(AppTheme.Light)); + } + + [Fact] + public void HandleOsThemeChanged_RaisesThemeChanged() + { + // Arrange + var preferenceService = Services.GetService(); + var raised = 0; + preferenceService.ThemeChanged += (_, _) => raised++; + + // Act + preferenceService.HandleOsThemeChanged(); + + // Assert + raised.Should().Be(1); + } + [Fact] public void SafetyPlan_WithMalformedJson_ReturnsNull() { diff --git a/JournalApp.Tests/MaterialThemeTests.cs b/JournalApp.Tests/MaterialThemeTests.cs index 06573e0..963fdf0 100644 --- a/JournalApp.Tests/MaterialThemeTests.cs +++ b/JournalApp.Tests/MaterialThemeTests.cs @@ -16,6 +16,8 @@ public void DefaultSeedReproducesOrchidPalette() Hex(theme.PaletteLight.PrimaryLighten).Should().Be("#FFD8EE"); Hex(theme.PaletteLight.PrimaryDarken).Should().Be("#69345A"); Hex(theme.PaletteLight.Secondary).Should().Be("#705766"); + Hex(theme.PaletteLight.SecondaryLighten).Should().Be("#FADAEB"); + Hex(theme.PaletteLight.SecondaryDarken).Should().Be("#57404E"); Hex(theme.PaletteLight.Tertiary).Should().Be("#81533F"); Hex(theme.PaletteLight.Error).Should().Be("#BA1A1A"); Hex(theme.PaletteLight.Info).Should().Be("#1A59C2"); @@ -38,6 +40,8 @@ public void DefaultSeedReproducesOrchidPalette() Hex(theme.PaletteDark.PrimaryLighten).Should().Be("#69345A"); Hex(theme.PaletteDark.PrimaryDarken).Should().Be("#FFD8EE"); Hex(theme.PaletteDark.Secondary).Should().Be("#DDBECF"); + Hex(theme.PaletteDark.SecondaryLighten).Should().Be("#57404E"); + Hex(theme.PaletteDark.SecondaryDarken).Should().Be("#FADAEB"); Hex(theme.PaletteDark.Tertiary).Should().Be("#F4B9A0"); Hex(theme.PaletteDark.Error).Should().Be("#FFB4AB"); Hex(theme.PaletteDark.Info).Should().Be("#B0C6FF"); @@ -64,4 +68,49 @@ public void OtherSeedsProduceDistinctPalettes() Hex(blue.PaletteLight.Primary).Should().NotBe("#844C72"); Hex(blue.PaletteLight.Background).Should().NotBe(Hex(blue.PaletteLight.Surface), "the surface container ladder should keep distinct tones"); } + + [Fact] + public void ToggleSegmentPairsStayLegibleForAnySeed() + { + // Light mode surfaces are only ~1.05:1 apart, so the toggle group leans on chroma and on a filled primary selection instead of tone. + // These are the pairs app.css actually draws, and every one of them has to clear the M3 4.5:1 text floor whatever seed the device supplies. + foreach (var seed in new uint[] { MaterialTheme.DefaultSeed, 0xFF4285F4, 0xFF4CAF50, 0xFFFF9800, 0xFF000000, 0xFFFFFFFF }) + { + var theme = MaterialTheme.FromSeed(seed); + + foreach (var palette in new Palette[] { theme.PaletteLight, theme.PaletteDark }) + { + Contrast(palette.SecondaryDarken, palette.SecondaryLighten).Should() + .BeGreaterThan(4.5, $"an unselected segment label must read on its tonal fill (seed {seed:X8})"); + + Contrast(palette.PrimaryContrastText, palette.Primary).Should() + .BeGreaterThan(4.5, $"a selected segment label must read on the filled primary pill (seed {seed:X8})"); + + Contrast(palette.Primary, palette.SecondaryLighten).Should() + .BeGreaterThan(3, $"the selected segment must separate from its unselected neighbours (seed {seed:X8})"); + + Contrast(palette.Primary, palette.Surface).Should() + .BeGreaterThan(3, $"the selected segment must separate from the row it sits on (seed {seed:X8})"); + } + } + } + + private static double Contrast(MudColor a, MudColor b) + { + var la = RelativeLuminance(a); + var lb = RelativeLuminance(b); + + return la > lb ? (la + 0.05) / (lb + 0.05) : (lb + 0.05) / (la + 0.05); + } + + private static double RelativeLuminance(MudColor color) + { + static double Channel(byte value) + { + var srgb = value / 255.0; + return srgb <= 0.03928 ? srgb / 12.92 : Math.Pow((srgb + 0.055) / 1.055, 2.4); + } + + return (0.2126 * Channel(color.R)) + (0.7152 * Channel(color.G)) + (0.0722 * Channel(color.B)); + } } diff --git a/JournalApp/Components/DataPointView.razor b/JournalApp/Components/DataPointView.razor index 0768125..19d0c96 100644 --- a/JournalApp/Components/DataPointView.razor +++ b/JournalApp/Components/DataPointView.razor @@ -21,7 +21,8 @@ else if (Point.Type == PointType.Sleep) } else if (Point.Type == PointType.Scale) { - + @* A filled and an outlined dot, because MudRating paints both icons in the same color and two filled circles would look identical. *@ + } else if (Point.Type == PointType.LowToHigh) { diff --git a/JournalApp/Components/JaMessageBox.razor b/JournalApp/Components/JaMessageBox.razor index 8717432..ac3d305 100644 --- a/JournalApp/Components/JaMessageBox.razor +++ b/JournalApp/Components/JaMessageBox.razor @@ -3,13 +3,13 @@ - @if (TitleContent is null) + @if (TitleContent is not null) { - @Title + @TitleContent } - else + else if (!string.IsNullOrWhiteSpace(Title)) { - @TitleContent + @Title } diff --git a/JournalApp/Data/MaterialTheme.cs b/JournalApp/Data/MaterialTheme.cs index 5ce4140..cb79ffc 100644 --- a/JournalApp/Data/MaterialTheme.cs +++ b/JournalApp/Data/MaterialTheme.cs @@ -59,15 +59,15 @@ public static MudTheme FromSeed(uint seed) Info = Hex(info[40]), InfoContrastText = "#FFFFFF", InfoLighten = Hex(info[90]), - InfoDarken = Hex(info[10]), + InfoDarken = Hex(info[30]), Success = Hex(success[40]), SuccessContrastText = "#FFFFFF", SuccessLighten = Hex(success[90]), - SuccessDarken = Hex(success[10]), + SuccessDarken = Hex(success[30]), Warning = Hex(warning[40]), WarningContrastText = "#FFFFFF", WarningLighten = Hex(warning[90]), - WarningDarken = Hex(warning[10]), + WarningDarken = Hex(warning[30]), Background = Hex(neutral[98]), BackgroundGray = Hex(neutral[96]), Surface = Hex(neutral[94]), @@ -85,6 +85,9 @@ public static MudTheme FromSeed(uint seed) Dark = Hex(neutral[20]), DarkContrastText = Hex(neutral[95]), + // M3 scrim is the neutral black at 32%; MudBlazor's default is a grey that lightens the page in dark mode. + OverlayDark = "rgba(0,0,0,0.32)", + HoverOpacity = 0.08, }, @@ -135,16 +138,26 @@ public static MudTheme FromSeed(uint seed) Dark = Hex(neutral[90]), DarkContrastText = Hex(neutral[20]), + // M3 scrim is the neutral black at 32%; MudBlazor's default is a grey that lightens the page in dark mode. + OverlayDark = "rgba(0,0,0,0.32)", + HoverOpacity = 0.08, }, LayoutProperties = new() { - DefaultBorderRadius = "8px", + // The M3 medium corner, so anything not styled by hand still lands on a real shape token. + DefaultBorderRadius = "12px", }, Typography = new() { + Default = new DefaultTypography() + { + // The device's own UI font is what makes a WebView app read as native; Roboto is the Android fallback. + FontFamily = ["system-ui", "Roboto", "Helvetica", "Arial", "sans-serif"], + }, + Button = new ButtonTypography() { TextTransform = "none", diff --git a/JournalApp/Data/PreferenceService.cs b/JournalApp/Data/PreferenceService.cs index f9bf1be..d23fea3 100644 --- a/JournalApp/Data/PreferenceService.cs +++ b/JournalApp/Data/PreferenceService.cs @@ -52,7 +52,7 @@ public PreferenceService(ILogger logger, IPreferences prefere _application.RequestedThemeChanged += Application_RequestedThemeChanged; } - UpdateStatusBar(); + ApplyPlatformTheme(); } public AppTheme SelectedAppTheme @@ -181,26 +181,44 @@ public DateTimeOffset LastExportDate public event EventHandler ThemeChanged; - private void Application_RequestedThemeChanged(object sender, AppThemeChangedEventArgs e) - { - _theme = e.RequestedTheme; - OnThemeChanged(); - } + private void Application_RequestedThemeChanged(object sender, AppThemeChangedEventArgs e) => HandleOsThemeChanged(); + + /// + /// Repaints for an OS theme change without touching the theme the user picked, which stays whatever they chose including System. + /// + internal void HandleOsThemeChanged() => OnThemeChanged(); private void OnThemeChanged() { - UpdateStatusBar(); + ApplyPlatformTheme(); ThemeChanged?.Invoke(this, IsDarkMode); } - private void UpdateStatusBar() + /// + /// Pushes the active theme out to the native chrome that the web layer can't reach. + /// + public void ApplyPlatformTheme() { + if (_application == null) + return; + + logger.LogDebug("Applying platform theme"); + + // The window's appearance drives the native resources the WebView sits inside, so it follows the in-app choice rather than only the OS. + if (_application.UserAppTheme != SelectedAppTheme) + _application.UserAppTheme = SelectedAppTheme; + + var surface = IsDarkMode ? GetTheme().PaletteDark.Background : GetTheme().PaletteLight.Background; + var background = Color.FromRgb(surface.R, surface.G, surface.B); + + // On Android 15 and up the system bars are transparent, so what shows behind them is this page background rather than any status bar color we set. + if (_application.Windows.Count > 0 && _application.Windows[0].Page is ContentPage page) + page.BackgroundColor = background; + if (OperatingSystem.IsAndroid()) { - logger.LogDebug("Updating status bar"); - // Match the M3 surface tone so the status bar blends into the page header. - var surface = IsDarkMode ? GetTheme().PaletteDark.Background : GetTheme().PaletteLight.Background; - StatusBar.SetColor(Color.FromRgb(surface.R, surface.G, surface.B)); + // Still needed below Android 15, where the status bar has its own color instead of showing the page through. + StatusBar.SetColor(background); StatusBar.SetStyle(IsDarkMode ? StatusBarStyle.LightContent : StatusBarStyle.DarkContent); } } diff --git a/JournalApp/MainPage.xaml b/JournalApp/MainPage.xaml index 24477a3..2562bb7 100644 --- a/JournalApp/MainPage.xaml +++ b/JournalApp/MainPage.xaml @@ -5,6 +5,7 @@ x:Class="JournalApp.MainPage" SafeAreaEdges="Container" BackgroundColor="{AppThemeBinding Light=#FFF8F9, Dark=#181215}"> + diff --git a/JournalApp/Pages/Calendar/CalendarDay.razor.css b/JournalApp/Pages/Calendar/CalendarDay.razor.css index d460f81..14fa6f7 100644 --- a/JournalApp/Pages/Calendar/CalendarDay.razor.css +++ b/JournalApp/Pages/Calendar/CalendarDay.razor.css @@ -9,11 +9,14 @@ color: var(--mud-palette-text-primary); } +/* Large bold type keeps the day number legible on the deepest mood fills, where the dark ramp only reaches 3.5:1 against the theme ink. */ .calendar-day-number { position: absolute; top: 0; left: 0; - font-weight: bold; + font-size: 20px; + line-height: 1; + font-weight: 700; } .calendar-day-emoji { diff --git a/JournalApp/Pages/Calendar/CalendarMonth.razor.css b/JournalApp/Pages/Calendar/CalendarMonth.razor.css index 2ba557c..db64492 100644 --- a/JournalApp/Pages/Calendar/CalendarMonth.razor.css +++ b/JournalApp/Pages/Calendar/CalendarMonth.razor.css @@ -5,10 +5,13 @@ padding-top: 16px; } +/* The month grid is a group container like every other list group: flat, tonal, large radius. */ ::deep .calendar-month-grid { display: flex; flex-direction: column; - padding: 0; + padding: 4px; + border-radius: var(--ja-shape-lg-increased); + box-shadow: none; } ::deep .calendar-day-cell { @@ -18,14 +21,27 @@ padding: 1%; min-width: 32px; max-width: 96px; - border-radius: 8px; - transition: transform 0.1s ease-out; + border-radius: var(--ja-shape-md); + transition: transform var(--ja-motion-spatial); } ::deep .calendar-day-cell:has(:not(.calendar-day-empty)):active { transform: scale(0.95); } +/* M3 calendar weekday labels are label-medium on onSurfaceVariant, not bold body text. */ +::deep .calendar-day-header { + justify-content: center; + color: var(--mud-palette-text-secondary); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.5px; +} + +::deep .calendar-day-header b { + font-weight: 500; +} + /* M3 marks today with a solid primary ring instead of a dashed generic outline. */ ::deep .calendar-day-current { outline: 3px solid var(--mud-palette-primary); diff --git a/JournalApp/Pages/ManageCategoriesPage.razor.css b/JournalApp/Pages/ManageCategoriesPage.razor.css index a0bac21..e63a247 100644 --- a/JournalApp/Pages/ManageCategoriesPage.razor.css +++ b/JournalApp/Pages/ManageCategoriesPage.razor.css @@ -2,7 +2,7 @@ .manage-list { display: flex; flex-direction: column; - gap: 3px; + gap: 4px; } .manage-category { @@ -11,18 +11,18 @@ align-items: center; gap: 4px; background-color: var(--mud-palette-surface); - border-radius: 6px; + border-radius: var(--ja-shape-sm); padding: 6px 14px 6px 6px; } .manage-category:first-child { - border-top-left-radius: 18px; - border-top-right-radius: 18px; + border-top-left-radius: var(--ja-shape-lg-increased); + border-top-right-radius: var(--ja-shape-lg-increased); } .manage-category:last-child { - border-bottom-left-radius: 18px; - border-bottom-right-radius: 18px; + border-bottom-left-radius: var(--ja-shape-lg-increased); + border-bottom-right-radius: var(--ja-shape-lg-increased); } ::deep .manage-category-edit-button { diff --git a/JournalApp/Pages/SafetyPlanning/SafetyPlanPage.razor.css b/JournalApp/Pages/SafetyPlanning/SafetyPlanPage.razor.css index 02af801..563838f 100644 --- a/JournalApp/Pages/SafetyPlanning/SafetyPlanPage.razor.css +++ b/JournalApp/Pages/SafetyPlanning/SafetyPlanPage.razor.css @@ -2,7 +2,7 @@ .safety-plan-items-container { display: flex; flex-direction: column; - gap: 3px; + gap: 4px; } ::deep .safety-plan-item { @@ -10,18 +10,18 @@ flex-direction: column; gap: 12px; background-color: var(--mud-palette-surface); - border-radius: 6px; + border-radius: var(--ja-shape-sm); padding: 14px 16px; } ::deep .safety-plan-item:first-child { - border-top-left-radius: 18px; - border-top-right-radius: 18px; + border-top-left-radius: var(--ja-shape-lg-increased); + border-top-right-radius: var(--ja-shape-lg-increased); } ::deep .safety-plan-item:last-child { - border-bottom-left-radius: 18px; - border-bottom-right-radius: 18px; + border-bottom-left-radius: var(--ja-shape-lg-increased); + border-bottom-right-radius: var(--ja-shape-lg-increased); } ::deep .safety-plan-item-header { diff --git a/JournalApp/Pages/SettingsPage.razor.css b/JournalApp/Pages/SettingsPage.razor.css index e62d60a..971eef0 100644 --- a/JournalApp/Pages/SettingsPage.razor.css +++ b/JournalApp/Pages/SettingsPage.razor.css @@ -8,7 +8,7 @@ .settings-group { display: flex; flex-direction: column; - gap: 3px; + gap: 4px; } ::deep .settings-item { @@ -16,18 +16,18 @@ flex-direction: column; gap: 12px; background-color: var(--mud-palette-surface); - border-radius: 6px; + border-radius: var(--ja-shape-sm); padding: 14px 16px; } ::deep .settings-item:first-child { - border-top-left-radius: 18px; - border-top-right-radius: 18px; + border-top-left-radius: var(--ja-shape-lg-increased); + border-top-right-radius: var(--ja-shape-lg-increased); } ::deep .settings-item:last-child { - border-bottom-left-radius: 18px; - border-bottom-right-radius: 18px; + border-bottom-left-radius: var(--ja-shape-lg-increased); + border-bottom-right-radius: var(--ja-shape-lg-increased); } ::deep .settings-item-title { diff --git a/JournalApp/Pages/Trends/TrendsPage.razor.css b/JournalApp/Pages/Trends/TrendsPage.razor.css index e2bba2d..45cd320 100644 --- a/JournalApp/Pages/Trends/TrendsPage.razor.css +++ b/JournalApp/Pages/Trends/TrendsPage.razor.css @@ -10,9 +10,10 @@ flex-direction: column; } +/* Each chart sits in a group container, so it takes the same large radius as a grouped list. */ ::deep .trend-view { padding: 8px; gap: 16px; background-color: var(--mud-palette-surface); - border-radius: 16px; + border-radius: var(--ja-shape-lg-increased); } diff --git a/JournalApp/Pages/Worksheets/WorksheetsPage.razor.css b/JournalApp/Pages/Worksheets/WorksheetsPage.razor.css index 90eb448..52924fd 100644 --- a/JournalApp/Pages/Worksheets/WorksheetsPage.razor.css +++ b/JournalApp/Pages/Worksheets/WorksheetsPage.razor.css @@ -13,7 +13,7 @@ .worksheet-group-items { display: flex; flex-direction: column; - gap: 3px; + gap: 4px; list-style-type: none; padding: 0; margin: 0; @@ -25,18 +25,18 @@ align-items: start; gap: 4px; background-color: var(--mud-palette-surface); - border-radius: 6px; + border-radius: var(--ja-shape-sm); padding: 14px 16px; } ::deep .worksheet-view:first-child { - border-top-left-radius: 18px; - border-top-right-radius: 18px; + border-top-left-radius: var(--ja-shape-lg-increased); + border-top-right-radius: var(--ja-shape-lg-increased); } ::deep .worksheet-view:last-child { - border-bottom-left-radius: 18px; - border-bottom-right-radius: 18px; + border-bottom-left-radius: var(--ja-shape-lg-increased); + border-bottom-right-radius: var(--ja-shape-lg-increased); } ::deep .worksheet-view-title { @@ -59,10 +59,11 @@ margin: 0; } +/* M3 assist chips: a medium-radius container with the outline role, the only neutral boundary that clears 3:1 on a light surface. */ .worksheet-group-toc-item { white-space: nowrap; - border: 1px solid var(--mud-palette-lines-default); - border-radius: 8px; + border: 1px solid var(--mud-palette-lines-inputs); + border-radius: var(--ja-shape-md); padding: 6px 12px; } diff --git a/JournalApp/wwwroot/app.css b/JournalApp/wwwroot/app.css index aaadd96..7b62235 100644 --- a/JournalApp/wwwroot/app.css +++ b/JournalApp/wwwroot/app.css @@ -1,4 +1,4 @@ -* { +* { -webkit-tap-highlight-color: transparent; } @@ -6,12 +6,44 @@ body { user-select: none; --mud-palette-action-default: var(--mud-palette-text-primary); color-scheme: light dark; + + /* The M3 shape scale, so every container picks its radius from one ladder instead of an ad-hoc number. */ + --ja-shape-xs: 4px; + --ja-shape-sm: 8px; + --ja-shape-md: 12px; + --ja-shape-lg: 16px; + --ja-shape-lg-increased: 20px; + --ja-shape-xl: 28px; + --ja-shape-full: 999px; + + /* M3 Expressive motion. Spatial springs are underdamped so size, position and shape overshoot slightly. */ + --ja-motion-spatial: 300ms cubic-bezier(0.27, 1.06, 0.18, 1); + /* Effects springs are critically damped, so color and opacity must never overshoot. */ + --ja-motion-effects: 200ms cubic-bezier(0.2, 0, 0, 1); + + /* The two soft lifts the app uses: a container resting above the page, and the header once content passes under it. */ + --ja-shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.10), 0 2px 6px 1px rgba(0, 0, 0, 0.07); + --ja-shadow-scrolled: 0 6px 10px -6px rgba(0, 0, 0, 0.16), 0 10px 24px -10px rgba(0, 0, 0, 0.10); + /* M3 elevation level 3, which is what dialogs and menus sit at. */ + --ja-shadow-level3: 0 1px 3px rgba(0, 0, 0, 0.30), 0 4px 8px 3px rgba(0, 0, 0, 0.15); } body::-webkit-scrollbar { display: none; } +/* Text selection and the caret otherwise fall back to the platform accent, which is the stock blue rather than the app's primary. */ +::selection { + background-color: var(--mud-palette-primary-lighten); + color: var(--mud-palette-primary-darken); +} + +input, +textarea, +[contenteditable] { + caret-color: var(--mud-palette-primary); +} + /* Background colors come from the theme-matched media queries at the bottom so the native splash, this loading screen, and the app all share one tone. */ #splash-screen { display: flex; @@ -28,30 +60,36 @@ body { font-size: 6vw; } -/* The sticky header ends flush at the pill so scrolling content passes under a clean edge instead of a same-color strip. */ +/* The day, month and year switcher is a full-corner container that rests above the page, like an M3 search bar. */ +/* It carries its own lift instead of relying on a full-width line under the header, which used to slice straight through the pill. */ .switcher { display: flex; justify-content: center; align-items: center; - border-radius: 999px; + height: 48px; + border-radius: var(--ja-shape-full); margin: 0 8px; - background-color: var(--mud-palette-surface); + background-color: var(--mud-palette-gray-lighter); + box-shadow: var(--ja-shadow-raised); } .switcher-header { display: flex; justify-content: center; - width: 55vw; + flex: 1; + min-width: 0; } .page { } +/* M3 small top app bar: 64dp tall, title-large title, trailing icons on the neutral action color. */ .page-toolbar { display: flex; flex-direction: row; align-items: center; gap: 8px; + min-height: 64px; padding: 0; box-shadow: none; background-color: var(--mud-palette-background); @@ -65,17 +103,24 @@ body { font-weight: 400; } +/* The leading navigation icon is onSurface; only the trailing icons are onSurfaceVariant. */ +.page-toolbar > .mud-icon-button:first-child { + color: var(--mud-palette-text-primary); +} + /* Match the date button to title-large as well. */ .switcher-header .mud-typography-h5 { font-size: 22px; font-weight: 400; } +/* The toolbar and switcher rows together make up a 120dp two-row app bar, with 8dp under the pill so scrolling content never touches it. */ .page-header { z-index: var(--mud-zindex-appbar); display: flex; flex-direction: column; padding: 0 !important; + padding-bottom: 8px !important; margin: 0 !important; top: 0 !important; position: sticky !important; @@ -83,7 +128,7 @@ body { color: var(--mud-palette-text-primary); } -/* On-scroll separation for the sticky header: a soft M3 level-1 shadow fades in once content passes underneath. */ +/* On-scroll separation for the sticky header: a soft lift fades in once content passes underneath. */ /* MD3 proper would retone the container instead, but that desyncs the natively colored status bar, so elevation stands in until edge-to-edge lands. */ @supports (animation-timeline: scroll()) { .page-header { @@ -93,9 +138,11 @@ body { } } +/* The lift carries an outlineVariant hairline as well, because a black shadow does nothing over a tone-6 dark surface. */ +/* The hairline has to come from the palette rather than a prefers-color-scheme branch: the WebView reports the OS theme, not the theme the user picked in the app. */ @keyframes header-on-scroll { to { - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); + box-shadow: 0 1px 0 var(--mud-palette-lines-default), var(--ja-shadow-scrolled); } } @@ -105,7 +152,7 @@ body { margin: 0 auto !important; padding: 8px !important; padding-bottom: 15vh !important; - animation: fadeInUp 0.15s ease-out; + animation: fadeInUp var(--ja-motion-spatial); } @keyframes fadeInUp { @@ -143,95 +190,158 @@ body { } /* M3E connected button group: tonal segments separated by 2px gaps instead of an outlined pill with dividers. */ -/* The row of segments keeps fully rounded outer corners, and the selected segment morphs to a full pill with a secondaryContainer fill. */ +/* The row of segments keeps fully rounded outer corners, and the selected segment morphs to a full pill. */ .mud-toggle-group { flex-grow: 1; gap: 2px; border: none !important; + border-radius: var(--ja-shape-full); background-color: transparent; } +/* MudBlazor pulls each segment 1px left to collapse outlined borders, which eats half of the 2dp M3E gap. */ .mud-toggle-group .mud-toggle-item { - color: var(--mud-palette-text-primary); - background-color: var(--mud-palette-gray-lighter); + margin-left: 0 !important; +} + +/* Tone alone cannot separate a control from its card in light mode, where neighbouring neutral surfaces are only 1.05:1 apart. */ +/* Chroma can: an unselected segment takes the tonal-button pair (secondaryContainer on onSecondaryContainer, 7.25:1), and the selected one is a filled primary pill (5.5:1 against the card). */ +.mud-toggle-group .mud-toggle-item { + color: var(--mud-palette-secondary-darken); + background-color: var(--mud-palette-secondary-lighten); border: none !important; - border-radius: 8px; - min-height: 32px; - transition: border-radius 0.15s ease-out, background-color 0.15s ease-out; + min-height: 48px; + font-size: 14px; + font-weight: 500; + border-radius: var(--ja-shape-sm); + transition: border-radius var(--ja-motion-spatial), background-color var(--ja-motion-effects), color var(--ja-motion-effects); } .mud-toggle-group .mud-toggle-item:first-child { - border-top-left-radius: 999px; - border-bottom-left-radius: 999px; + border-top-left-radius: var(--ja-shape-full); + border-bottom-left-radius: var(--ja-shape-full); } .mud-toggle-group .mud-toggle-item:last-child { - border-top-right-radius: 999px; - border-bottom-right-radius: 999px; + border-top-right-radius: var(--ja-shape-full); + border-bottom-right-radius: var(--ja-shape-full); } .mud-toggle-group .mud-toggle-item.mud-toggle-item-selected { - background-color: var(--mud-palette-secondary-lighten); - color: var(--mud-palette-secondary-darken); - border-radius: 999px; + background-color: var(--mud-palette-primary); + color: var(--mud-palette-primary-text); + border-radius: var(--ja-shape-full); } +/* M3E small buttons are 40dp tall; MudBlazor's own padding leaves them at ~36px. */ .mud-button { - border-radius: 999px; + border-radius: var(--ja-shape-full); + min-height: 40px; } -/* M3 outlined buttons and button groups use the neutral outline-variant, not a text- or primary-tinted border. */ -.mud-button-outlined { - border-color: var(--mud-palette-lines-default); +/* M3 outlined buttons and button groups draw their boundary in the outline role, which is the only neutral that clears 3:1 against a light surface. */ +/* The compound selectors are needed because MudBlazor's own per-color rules outrank a single class. */ +.mud-button-outlined, +.mud-button-outlined.mud-button-outlined-primary, +.mud-button-outlined.mud-button-outlined-default { + border-color: var(--mud-palette-lines-inputs); } .mud-button-group-outlined.mud-button-group-override-styles .mud-button-root { - border-color: var(--mud-palette-lines-default); + border-color: var(--mud-palette-lines-inputs); } +/* M3E connected button group geometry, the same one the toggle groups use: 2dp gaps, small inner corners, full outer corners. */ +/* MudBlazor collapses the inner corners to 0 and overlaps the members by 1px, which reads as a table rather than a button group. */ .mud-button-group-root { - border-radius: 999px; + border-radius: var(--ja-shape-full); + gap: 2px; +} + +.mud-button-group-horizontal:not(.mud-button-group-rtl) > .mud-button-root:not(:last-child), +.mud-button-group-horizontal:not(.mud-button-group-rtl) > :not(:last-child) .mud-button-root { + border-top-right-radius: var(--ja-shape-sm); + border-bottom-right-radius: var(--ja-shape-sm); +} + +.mud-button-group-horizontal:not(.mud-button-group-rtl) > .mud-button-root:not(:first-child), +.mud-button-group-horizontal:not(.mud-button-group-rtl) > :not(:first-child) .mud-button-root { + border-top-left-radius: var(--ja-shape-sm); + border-bottom-left-radius: var(--ja-shape-sm); + margin-left: 0; +} + +.mud-button-group-root > .mud-button-root:first-child { + border-top-left-radius: var(--ja-shape-full); + border-bottom-left-radius: var(--ja-shape-full); +} + +.mud-button-group-root > .mud-button-root:last-child { + border-top-right-radius: var(--ja-shape-full); + border-bottom-right-radius: var(--ja-shape-full); } .mud-button-filled.mud-button-filled-primary { color: var(--mud-palette-primary-text); } -/* M3 expressive slider: thick rounded track, primary fill, and a narrow bar handle instead of a floating circle. */ +/* M3E slider: a 16dp rounded track, primary active fill, and a narrow bar handle instead of a floating circle. */ +/* MudBlazor paints the range input above the active bar, so the inactive track moves onto the container beneath it and the active bar gets its own layer. */ /* Selectors include the size class because MudBlazor's size rules (e.g. .mud-slider-small) otherwise win the cascade. */ -.mud-slider .mud-slider-input::-webkit-slider-runnable-track, -.mud-slider.mud-slider-small .mud-slider-input::-webkit-slider-runnable-track { - height: 12px; - margin: 5px 0; - border-radius: 999px; +.mud-slider.mud-slider-primary .mud-slider-input::-webkit-slider-runnable-track, +.mud-slider.mud-slider-small .mud-slider-input::-webkit-slider-runnable-track, +.mud-slider.mud-slider-medium .mud-slider-input::-webkit-slider-runnable-track { + background-color: transparent; + background-image: none; + height: 16px; + margin: 3px 0; } /* The inactive track carries the M3E stop indicator dot near its trailing end. */ -.mud-slider.mud-slider-primary .mud-slider-input::-webkit-slider-runnable-track { +.mud-slider .mud-slider-inner-container::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 16px; + transform: translateY(-50%); + border-radius: var(--ja-shape-full); background-color: var(--mud-palette-secondary-lighten); background-image: radial-gradient(circle, var(--mud-palette-primary) 0 2px, transparent 2.5px); background-repeat: no-repeat; - background-position: right 5px center; + background-position: right 6px center; background-size: 5px 5px; } .mud-slider .mud-slider-filled, -.mud-slider.mud-slider-small .mud-slider-filled { - height: 12px; - border-radius: 6px 2px 2px 6px; +.mud-slider.mud-slider-small .mud-slider-filled, +.mud-slider.mud-slider-medium .mud-slider-filled { + position: relative; + z-index: 1; + height: 16px; + border-radius: var(--ja-shape-full); } .mud-slider .mud-slider-input::-webkit-slider-thumb, -.mud-slider.mud-slider-small .mud-slider-input::-webkit-slider-thumb { +.mud-slider.mud-slider-small .mud-slider-input::-webkit-slider-thumb, +.mud-slider.mud-slider-medium .mud-slider-input::-webkit-slider-thumb { width: 4px; - height: 24px; + height: 32px; transform: none; - border-radius: 2px; - margin-top: -6px; + border-radius: var(--ja-shape-full); + margin-top: -8px; + background-color: var(--mud-palette-primary); /* M3E carves a gap between the handle and the track; surface-colored blocks either side of the bar fake it over the row container. */ box-shadow: 6px 0 0 var(--mud-palette-surface), -6px 0 0 var(--mud-palette-surface); } +/* MudBlazor swaps in an M2 focus ring on press, which would drop the gap exactly while the handle is being dragged. */ +.mud-slider .mud-slider-input:active::-webkit-slider-thumb, +.mud-slider .mud-slider-input:focus::-webkit-slider-thumb { + box-shadow: 6px 0 0 var(--mud-palette-surface), -6px 0 0 var(--mud-palette-surface) !important; +} + /* M3 switch: 52x32 pill track with an outline, solid colors instead of translucent overlays, and a thumb that grows when checked. */ /* Selectors match MudBlazor's compound size and descendant rules, which otherwise outrank single-class overrides and leave the M2 geometry in place. */ .mud-switch-span-medium.mud-switch-span { @@ -241,7 +351,7 @@ body { } .mud-switch-span .mud-switch-track { - border-radius: 999px; + border-radius: var(--ja-shape-full); opacity: 1; background-color: var(--mud-palette-gray-light); border: 2px solid var(--mud-palette-lines-inputs); @@ -294,16 +404,43 @@ label.mud-switch.mud-disabled .mud-switch-span { } /* M3 settings rows lead with the label and keep the switch on the trailing edge. Scoped to the label element because MudSwitch reuses the mud-switch class on its inner text span. */ +/* The label is the whole tap target, so it takes the 48dp minimum rather than the 32px height of the switch itself. */ .settings-group label.mud-switch { width: 100%; + min-height: 48px; flex-direction: row-reverse; justify-content: space-between; margin-inline-start: 0; } -/* M3 menus and popovers sit on surfaceContainerHigh. */ +/* MudBlazor's text alerts are a low-alpha wash that all but disappears on a light surface, so they take a real tonal container instead. */ +.mud-alert-text-info { + background-color: var(--mud-palette-info-lighten); +} + +.mud-alert-text-warning { + background-color: var(--mud-palette-warning-lighten); +} + +.mud-alert-text-error { + background-color: var(--mud-palette-error-lighten); +} + +.mud-alert-text-success { + background-color: var(--mud-palette-success-lighten); +} + +/* Menus and popovers take surfaceContainerHighest, one step above the dialog container, so a dropdown opened inside a dialog still separates from it. */ .mud-popover { - --mud-palette-surface: var(--mud-palette-gray-lighter); + --mud-palette-surface: var(--mud-palette-gray-light); + border-radius: var(--ja-shape-lg); + max-width: calc(100vw - 16px); + box-shadow: var(--ja-shadow-level3); +} + +.mud-popover.mud-popover-top-right, +.mud-popover.mud-popover-bottom-right { + margin-right: 8px; } /* The keyboard overlays the WebView and shrinks the visual viewport; pinning the container to it glides dialogs out of the keyboard's way. */ @@ -322,12 +459,13 @@ body { padding-bottom: var(--keyboard-inset, 0px); } -/* M3 dialogs sit on surfaceContainerHigh with the 24px container inset. */ +/* M3 dialogs sit on surfaceContainerHigh with the 24px container inset, at elevation Level3 rather than MudBlazor's M2 elevation 24. */ .mud-dialog { padding: 24px !important; gap: 16px !important; - border-radius: 28px; + border-radius: var(--ja-shape-xl); --mud-palette-surface: var(--mud-palette-gray-lighter); + box-shadow: var(--ja-shadow-level3); } /* M3 dialog headline is headline-small on the leading edge, not a centered bold h6. */ @@ -338,6 +476,11 @@ body { justify-content: flex-start; } +/* Message boxes ship an empty title, which would otherwise leave a headline-sized gap above the message. */ +.mud-dialog-title:not(:has(*)) { + display: none; +} + .mud-dialog-title .mud-typography-h6 { font-size: 24px; font-weight: 400; @@ -349,6 +492,13 @@ body { color: var(--mud-palette-text-secondary); } +/* The rule above is for supporting text, so anything the user actually reads back or edits stays on onSurface. */ +.mud-dialog-content .mud-input-slot, +.mud-dialog-content .mud-input-root, +.mud-dialog-content .mud-select-input { + color: var(--mud-palette-text-primary); +} + .mud-dialog-actions { gap: 8px; justify-content: flex-end; @@ -358,7 +508,7 @@ body { .mud-card { padding: 12px !important; gap: 12px !important; - border-radius: 16px; + border-radius: var(--ja-shape-md); box-shadow: none; } @@ -389,28 +539,34 @@ body { display: none; } -/* M3 snackbar: inverse surface. */ -.mud-snackbar { +/* M3 snackbar: inverse surface. The compound selector is needed because MudBlazor's own .mud-snackbar-surface rule outranks a single class. */ +.mud-snackbar, +.mud-snackbar.mud-snackbar-surface { background-color: var(--mud-palette-dark); color: var(--mud-palette-dark-text); + border-radius: var(--ja-shape-xs); } +/* The mood button is a tonal container like the other controls, so it stays visible on a light card. */ .emoji-button { padding: 0; min-width: 48px; height: 48px; border-radius: 50%; - background-color: var(--mud-palette-gray-lighter); + background-color: var(--mud-palette-secondary-lighten); } .emoji-popover-content { display: flex; flex-direction: column; - border-radius: 12px; + border-radius: var(--ja-shape-lg); box-shadow: var(--mud-elevation-8); } -.list-group-title { +/* M3 list section headers are title-small on onSurfaceVariant. The link variants inherit onSurface, so they need naming here too. */ +.list-group-title, +.list-group-title.mud-link, +a.list-group-title { font-size: var(--mud-typography-subtitle2-size); font-family: var(--mud-typography-subtitle2-family); font-weight: var(--mud-typography-subtitle2-weight); @@ -428,38 +584,38 @@ body { .main-timeline .mud-card.data-point-group { background-color: transparent; padding: 0 !important; - gap: 3px !important; + gap: 4px !important; } .main-timeline .data-point-group .data-point-list { - gap: 3px; + gap: 4px; } .main-timeline .data-point-container { background-color: var(--mud-palette-surface); - border-radius: 6px; + border-radius: var(--ja-shape-sm); padding: 14px 16px; } .main-timeline .data-point-container:first-child { - border-top-left-radius: 18px; - border-top-right-radius: 18px; + border-top-left-radius: var(--ja-shape-lg-increased); + border-top-right-radius: var(--ja-shape-lg-increased); } .main-timeline .data-point-container:last-child { - border-bottom-left-radius: 18px; - border-bottom-right-radius: 18px; + border-bottom-left-radius: var(--ja-shape-lg-increased); + border-bottom-right-radius: var(--ja-shape-lg-increased); } /* When the group ends with an action row (New note), that row becomes the closing container instead of the last list item. */ .main-timeline .data-point-group:has(.mud-card-actions .mud-button) .data-point-container:last-child { - border-bottom-left-radius: 6px; - border-bottom-right-radius: 6px; + border-bottom-left-radius: var(--ja-shape-sm); + border-bottom-right-radius: var(--ja-shape-sm); } .main-timeline .mud-card-actions:has(.mud-button) { background-color: var(--mud-palette-surface); - border-radius: 6px 6px 18px 18px; + border-radius: var(--ja-shape-sm) var(--ja-shape-sm) var(--ja-shape-lg-increased) var(--ja-shape-lg-increased); padding: 12px 16px !important; } @@ -470,6 +626,62 @@ body { letter-spacing: 0.15px; } +/* M3 filled text field, so number and text rows read as editable instead of as plain labels. */ +/* Without a container these fields are borderless transparent text and an empty one is invisible. */ +.main-timeline .data-point-container-number .mud-input, +.main-timeline .data-point-container-text .mud-input { + background-color: var(--mud-palette-gray-light); + border-radius: var(--ja-shape-xs) var(--ja-shape-xs) 0 0; + border-bottom: 1px solid var(--mud-palette-lines-inputs); + padding: 4px 12px; + min-height: 40px; +} + +/* A number never needs the whole row, and a full-width box for three digits reads as a form rather than a journal entry. */ +.main-timeline .data-point-container-number .mud-input { + max-width: 200px; +} + +/* M3 filled fields thicken the active indicator to 2dp in primary while focused. */ +.main-timeline .data-point-container-number .mud-input:focus-within, +.main-timeline .data-point-container-text .mud-input:focus-within { + border-bottom: 2px solid var(--mud-palette-primary); + padding-bottom: 3px; +} + +/* The sleep stepper and the note edit pencil render at MudBlazor's small size, which is well under the 48dp target. */ +.sleep-controls .mud-icon-button, +.note-container .note-edit-button { + min-width: 48px; + min-height: 48px; +} + +/* ApexCharts bakes its own greys into the SVG it draws, so the axis ink, gridlines and tooltip are re-pointed at the palette here. */ +/* These rules can't live in the scoped stylesheet because the chart's SVG is created outside Blazor and carries no scope attribute. */ +.apexcharts-xaxis-label, +.apexcharts-yaxis-label { + fill: var(--mud-palette-text-secondary); +} + +.apexcharts-gridline, +.apexcharts-xaxis line, +.apexcharts-xaxis-tick { + stroke: var(--mud-palette-lines-default); +} + +.apexcharts-tooltip { + background-color: var(--mud-palette-dark) !important; + color: var(--mud-palette-dark-text) !important; + border: none !important; + border-radius: var(--ja-shape-xs) !important; + box-shadow: var(--ja-shadow-level3) !important; +} + +.apexcharts-tooltip-title { + background-color: var(--mud-palette-dark) !important; + border-bottom-color: var(--mud-palette-lines-default) !important; +} + #blazor-error-ui { background: darkorange; bottom: 0; @@ -500,6 +712,29 @@ body { } } +/* Android honors Remove animations under accessibility settings, so drop every entrance, transition and transform the app adds. */ +/* The scroll-driven header lift stays: it is tied to scroll position rather than to time, and it is the only cue that content is passing underneath. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } + + .page-body { + animation: none; + } + + .mud-overlay .mud-overlay-scrim { + animation: none; + } + + .calendar-day-cell:active { + transform: none; + } +} + @media (prefers-color-scheme: dark) { .status-bar-safe-area, #splash-screen { background-color: #181215; diff --git a/JournalApp/wwwroot/index.html b/JournalApp/wwwroot/index.html index bb44166..619a34a 100644 --- a/JournalApp/wwwroot/index.html +++ b/JournalApp/wwwroot/index.html @@ -7,7 +7,6 @@ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> Journal App - From afe9d89971e4cbedb92fd3f3d78f8b85d3e9f57d Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 27 Jul 2026 12:04:36 -0500 Subject: [PATCH 2/6] Close the remaining M3E gaps the audit turned up Touch targets that were nowhere near 48dp: settings and manage switch rows, row-wide links, the sleep stepper and the note pencil. The toolbar gains the 16dp leading inset Android expects, and the switcher steppers drop to onSurfaceVariant so the date reads as the primary action. Trends no longer renders an empty page body for a month with no entries or while a month is still loading, and dialog switch and panel labels keep onSurface instead of being demoted with the supporting text. --- JournalApp/Pages/Trends/TrendsPage.razor | 27 +++++++++++++++++++ JournalApp/Pages/Trends/TrendsPage.razor.css | 20 ++++++++++++++ JournalApp/wwwroot/app.css | 28 +++++++++++++++----- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/JournalApp/Pages/Trends/TrendsPage.razor b/JournalApp/Pages/Trends/TrendsPage.razor index f0b534b..1b4909d 100644 --- a/JournalApp/Pages/Trends/TrendsPage.razor +++ b/JournalApp/Pages/Trends/TrendsPage.razor @@ -17,6 +17,25 @@
+ @if (!_loaded) + { + + } + else if (AllPoints.Count == 0) + { + + } +