One Material 3 Expressive shape, color and motion system - #115
Merged
Conversation
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.
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.
The timeline stacks a toggle group on most rows, so the 48dp size cost too much of the screen. M3E's extra-small connected group is 32dp, which is where these started, and the contrast work is what carries legibility here rather than the size.
MudBlazor renders an unselected segment as an outlined button, and its own :active rule swapped the container to primary-hover, so the tonal fill vanished exactly while a finger was on it. M3 press feedback is a state layer over the resting fill, not a container swap. The slider handle faked its M3E gap with two offset shadows, which left a sliver of track showing on the trailing side, and the active bar was painting over the handle because it had to sit above the inactive track. A spread ring plus an explicit input layer gives the real three-layer stack: inactive track, active bar, handle.
Reading a day's entries is the point of the screen, and the pass had been spending vertical space on chrome instead. Everything that grew without being asked to goes back: the app bar height, button and switch and link minimums, the sleep stepper, and the calendar day number. The header only reserves space under itself on the pages that actually carry the day switcher. The mood button returns to its quiet neutral circle, and number rows trade the filled box for a single active indicator sized to the value rather than the row, which still says the field is editable without turning a journal entry into a form. Worksheet cards were ragged because the title is an inline link whose box hugs its text, so a wrapped title sat lower than a single-line one. Blocking the title out gives every card the same 14px above its first line.
The teardrop handle and magnifier that appear when you focus a field are native chrome drawn by the WebView, not CSS, and they take their color from colorControlActivated on the activity theme. The theme parented straight onto Theme.MaterialComponents and never set it, so they came out #03DAC6, the Material Components default secondary, a teal that belongs to no palette this app generates. The theme now carries the stock Orchid primary, per mode, and MainActivity applies a wallpaper-accent overlay over it while the user has device colors on, so the handles follow whichever palette the UI is actually using. Verified both ways on the emulator: #844C72 with device colors off, and an exact match to the generated primary with them on. The overlay is applied in OnCreate because the platform resolves handle drawables once, when the view is created, so toggling device colors only reaches the handles on the next launch.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Light mode had drifted well behind dark after #97, #103 and #111. Toggle buttons were the worst of it, and the sticky day switcher looked broken once you scrolled. This is a full pass over every surface rather than a patch on those two, and it introduces a small token system so the next component lands consistently instead of by hand.
Why light mode broke, with numbers
The palette is not at fault, and neither is anyone's taste. It is arithmetic:
Light mode's tonal step is about 2.5x weaker than dark's, and dE76 1.95 is at or below the just-noticeable-difference threshold. Every neutral-on-neutral pair in light mode is invisible, and no amount of tuning changes that:
I checked whether the surface ladder itself should move, and the spec says no. In the current Material Color Utilities
color_spec_2025.ts, the light surface container tones are fixed constants with no ContrastCurve, unlike the 2021 spec where they moved with contrast level. m3.material.io states it directly: "high contrast is applied to the content in a card but not the card container." The flat light ramp is intentional and permanent. That also matches what happened here previously, where two custom surface ramps were built, deployed and rejected on device.So tone cannot carry control visibility in light mode. Chroma can — the neutral palette is chroma 6, secondaryContainer is chroma 16, primaryContainer 36 — and HCT lets chroma change without touching tone, so it costs nothing in contrast terms.
The three conventions this PR introduces
1. Container fill. A control that sits on a tonal card never uses a neutral surface slot. Unselected takes the M3 tonal-button pair (
secondaryContainer/onSecondaryContainer), and the active state is a filledprimarypill (primary/onPrimary). That is the M3 filled-toggle-button selected role, and the docs explicitly permit substituting roles "as long as the container and text have a 3:1 contrast ratio".Dark mode gets the same treatment and stays strong (selected vs unselected 4.4:1).
2. Shape. A single M3 shape scale lives in
app.cssas custom properties and is used from the scoped.razor.cssfiles too, since the vars cascade:Group containers and chart cards take
lg-increased, rows inside a groupsm, dialogsxl, buttons and segmentsfull, snackbars and filled-field topsxs. Grouped lists moved off their ad-hoc 3/6/18px onto 4/8/20px.DefaultBorderRadiusmoved from 8px, which matches no M3E component, to the medium corner so anything not styled by hand still lands on a real token.3. Motion. Two tokens rather than one, because M3E's motion system distinguishes them:
--ja-motion-spatialovershoots slightly (M3E spatial springs are underdamped at 0.8 damping) and drives size, position and shape;--ja-motion-effectsdoes not overshoot (effects springs are critically damped at 1.0) and drives colour and opacity. Aprefers-reduced-motionbranch now honours Android's "Remove animations" setting.4. Boundaries. Everything that needs to read as an edge moved from
outlineVariant(1.46:1 on a light card, which M3 designates decorative) tooutline(3.86:1), which is the role for "important boundaries" and the only neutral that clears the 3:1 non-text floor in light mode.5. Density is a constraint, not a leftover. The first pass of this PR raised the app bar to the 64dp M3 height and pushed buttons, switch rows, links, the sleep stepper and calendar day numbers up to their 48dp accessibility minimums. On device that was clearly wrong: reading a day's entries is the whole point of the screen, and the chrome was eating it. All of that is reverted. Where a spec minimum and legibility of the actual content disagree here, the content wins, and the contrast work is what carries usability instead of size. The one place size did increase is the slider, whose 16dp track and bar handle replace a 4px rail that was never visible.
The scrolled header
The old sticky header ended flush at the bottom of the day pill, so the on-scroll shadow drew a hard
#A8A3A4hairline straight through the pill's rounded corners and content butted directly against it. Sampled pixel by pixel down the edge, the shadow went from#A8A3A4to background in about 6px, which reads as a rendering artefact rather than elevation.Now the pill is its own raised container (
surfaceContainerHigh, full corners, its own soft lift, the M3 search-bar treatment), the header holds 8dp underneath it so scrolling content never touches it, and the scroll cue is a soft lift plus anoutlineVarianthairline. The hairline is not cosmetic: a black shadow is invisible over a tone-6 dark surface, so without it dark mode had no scroll separation at all.The 8dp only applies to headers that actually carry a switcher; Settings, Worksheets and Safety Plan stay as tight as they were. The leading nav icon is
onSurfaceand the trailing and stepper icons areonSurfaceVariant, per spec.I did not retone the bar to
surfaceContaineron scroll, which is what M3 actually prescribes. On this app that desyncs the natively coloured status strip, and it was scrapped once before for exactly that reason. Elevation stands in until edge-to-edge lands.Defects the audit turned up
Several of these were invisible until measured or inspected in the live WebView over CDP:
.mud-slider-inner-containerbefore the range input, so the input paints over.mud-slider-filled; making the inactive track opaque hid the fill completely. Fixed with a real three-layer stack: transparent runnable track, inactive track on the container's::before, active bar on its own layer, input lifted above both so the handle and its 6dp gaps survive.MudRatingpaintedFullIconandEmptyIconin the same colour, and both wereCircle, so Scale rows showed no value whatsoever. The empty icon is now hollow.MudButtonGroupcollapsed inner corners to 0 and overlapped members by 1px, so Export/Import read as a table. It now uses the same connected-group geometry as the toggles.rgba(33,33,33,0.5), which lightens the page in dark mode. Now the M3 scrim, black at 32%, set on the palette slot rather than patched in CSS.MudLinkwhose box hugs its text, so a card with a wrapped title had visibly more space above it than one with a single-line title. Blocking the title out gives every card the same 14px above its first line.Native chrome
The teardrop handle and magnifier that appear when you focus a field are native chrome drawn by the WebView, not anything CSS can reach, and they take their colour from
colorControlActivatedon the activity theme. The theme parented straight ontoTheme.MaterialComponentsand never set it, so they came out#03DAC6, the Material Components default secondary: a teal belonging to no palette this app generates. The theme now carries the Orchid primary per mode, andMainActivityapplies a wallpaper-accent overlay over it while the user has device colours on, so the handles follow whichever palette the UI is actually using. Verified both ways on the emulator:#844C72with device colours off, and an exact match to the generated primary with them on. The overlay is applied inOnCreatebecause the platform resolves handle drawables once, when the view is created, so toggling device colours only reaches the handles on the next launch.::selectionandcaret-colorare also pointed at the palette, which covers the parts of the selection UI that are web-layer.At targetSdk 35+
setStatusBarColoris a no-op, so what actually tints the status bar is the MAUI page background showing through the transparent bar. That background was hardcoded to the Orchid seed, which meant Material You never reached the letterbox.PreferenceService.ApplyPlatformThemenow drives it from the generated palette, setsUserAppThemeso the native layer follows the in-app choice, and keepsStatusBar.SetColorfor devices below Android 15 where it still does something.While wiring that up I found
Application_RequestedThemeChangedwas overwriting the user's explicit theme with the OS theme, so picking Light or Dark silently reverted to whatever the system was doing. Fixed, with tests.Tests
239 passing, up from 236.
MaterialThemeTests.ToggleSegmentPairsStayLegibleForAnySeedsweeps six seeds including black and white and asserts all four toggle pairs hold, so a Material You wallpaper cannot quietly break the contrast fix.SecondaryLightenandSecondaryDarkenare now pinned, since the CSS depends on them.Verification
Everything was checked on a real Android emulator in both themes, at every step, including pixel sampling of the rendered output and computed-style inspection over the DevTools protocol. Not from mockups. One of my own changes was caught that way: I had branched the dark-mode header hairline on
prefers-color-scheme, then measured that the WebView reports the OS theme, not the app's theme, so an in-app override would have fired the wrong branch. It now derives from the palette instead.Screenshots
Home, light
Home scrolled, light — the header complaint
Home scrolled, dark
Settings, light
Calendar, light
Trends, light
Overflow menu, light
Deliberately not done
.mud-toggle-group .mud-toggle-item:not(.mud-toggle-item-selected) { box-shadow: inset 0 0 0 1px var(--mud-palette-lines-inputs); }Known issues, unchanged by this PR
UserAppThemedoes not fix it.<p>, so TalkBack cannot navigate sections by heading. Calendar day cells have no accessible role or name.