Skip to content

One Material 3 Expressive shape, color and motion system - #115

Merged
danielchalmers merged 6 commits into
mainfrom
claude/light-theme-md3-contrast-25abc1
Jul 27, 2026
Merged

One Material 3 Expressive shape, color and motion system#115
danielchalmers merged 6 commits into
mainfrom
claude/light-theme-md3-contrast-25abc1

Conversation

@danielchalmers

@danielchalmers danielchalmers commented Jul 27, 2026

Copy link
Copy Markdown
Owner

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:

adjacent container step light dark
WCAG contrast 1.05:1 1.13:1
CIELAB dE76 1.95 4.81

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:

light pair ratio
surfaceContainerLow vs surface 1.06:1
surfaceContainer vs surface 1.11:1
surfaceContainerHigh vs surface 1.17:1
surfaceContainerHighest vs surface 1.23:1
unselected segment vs its card 1.05:1
selected segment vs unselected 1.05:1

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 filled primary pill (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".

toggle group, light before after
selected vs unselected 1.05:1 5.00:1
selected vs card 1.05:1 5.53:1
unselected label on its fill 14.69:1 7.25:1
selected label on its fill 7.25:1 6.45:1

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.css as custom properties and is used from the scoped .razor.css files too, since the vars cascade:

--ja-shape-xs 4  --ja-shape-sm 8  --ja-shape-md 12
--ja-shape-lg 16  --ja-shape-lg-increased 20  --ja-shape-xl 28  --ja-shape-full 999

Group containers and chart cards take lg-increased, rows inside a group sm, dialogs xl, buttons and segments full, snackbars and filled-field tops xs. Grouped lists moved off their ad-hoc 3/6/18px onto 4/8/20px. DefaultBorderRadius moved 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-spatial overshoots slightly (M3E spatial springs are underdamped at 0.8 damping) and drives size, position and shape; --ja-motion-effects does not overshoot (effects springs are critically damped at 1.0) and drives colour and opacity. A prefers-reduced-motion branch 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) to outline (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 #A8A3A4 hairline straight through the pill's rounded corners and content butted directly against it. Sampled pixel by pixel down the edge, the shadow went from #A8A3A4 to 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 an outlineVariant hairline. 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 onSurface and the trailing and stepper icons are onSurfaceVariant, per spec.

I did not retone the bar to surfaceContainer on 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:

  • The slider's active track never rendered at all. MudBlazor emits .mud-slider-inner-container before 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.
  • Number and Text rows had no container, so an unedited Weight field was literally invisible. They now carry a single active indicator, sized to the value rather than the row, which turns primary on focus.
  • MudRating painted FullIcon and EmptyIcon in the same colour, and both were Circle, so Scale rows showed no value whatsoever. The empty icon is now hollow.
  • Pressing an unselected segment swapped its container to primary-hover, so the tonal fill vanished under your finger. Press is now a state layer over the resting fill.
  • MudButtonGroup collapsed 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.
  • Message boxes rendered an empty title row, costing a headline of dead space above every confirmation.
  • Charts baked ApexCharts' own greys into the SVG: axis ink, the x-axis baseline, tick marks and the tooltip all ignored the palette. Re-pointed at palette roles from global CSS, since the chart SVG is created outside Blazor and carries no scope attribute.
  • Trends rendered a completely blank page for a month with no entries, and while loading. Both now have states.
  • The scrim was MudBlazor's hardcoded 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.
  • Worksheet cards were ragged. The title is an inline MudLink whose 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 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 belonging to no palette this app generates. The theme now carries the Orchid primary per mode, and MainActivity applies 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: #844C72 with device colours 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 colours only reaches the handles on the next launch.

::selection and caret-color are also pointed at the palette, which covers the parts of the selection UI that are web-layer.

At targetSdk 35+ setStatusBarColor is 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.ApplyPlatformTheme now drives it from the generated palette, sets UserAppTheme so the native layer follows the in-app choice, and keeps StatusBar.SetColor for devices below Android 15 where it still does something.

While wiring that up I found Application_RequestedThemeChanged was 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.ToggleSegmentPairsStayLegibleForAnySeed sweeps six seeds including black and white and asserts all four toggle pairs hold, so a Material You wallpaper cannot quietly break the contrast fix. SecondaryLighten and SecondaryDarken are 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

  • The M3E tinted-neutral ramp. The 2025 spec applies a chromaMultiplier (1.4/1.5/1.7) to the light surface containers, which is genuinely current spec, but it pushes every surface pinker, which is the direction that was rejected on device twice before. Happy to try it behind a flag if you want to see it.
  • The full M3 type scale. Adopt the M3 type scale on self-hosted Roboto Flex #109 was closed, so typography stays as it is apart from putting the platform font first and dropping the Google Fonts request, which was a network fetch for a font Android already has.
  • A 3:1 boundary on unselected segments and on the slider's inactive track. M3's clustered-container rule technically asks for one, and the fills are 1.11:1 against the card. I built it, looked at it on device, and it read busy: four outlined chips per row instead of one control. The filled selection carries the group instead. One line brings it back if you disagree: .mud-toggle-group .mud-toggle-item:not(.mud-toggle-item-selected) { box-shadow: inset 0 0 0 1px var(--mud-palette-lines-inputs); }
  • Scroll-under app bar retinting, a bottom nav bar, a FAB, press-squish on rows. All previously scrapped.

Known issues, unchanged by this PR

  • Several elements remain below the 48dp touch minimum: the sleep stepper, the note edit pencil, rating dots, "Edit dose", and calendar day cells. That is a deliberate trade against density on a screen this dense, not an oversight.
  • Dark-mode calendar day numbers sit at 3.46:1 on the deepest mood fills, short of 4.5:1. The two fixes available are a larger number or a darker mood ramp, and both were rejected: the number looked wrong at 20px, and the Coral Reef ramp was signed off separately.
  • The navigation bar is correct on cold start and still stale after a runtime theme switch (Theme the system navigation bar for edge-to-edge #110). UserAppTheme does not fix it.
  • Group labels are <p>, so TalkBack cannot navigate sections by heading. Calendar day cells have no accessible role or name.
  • Disabled categories still cannot be edited or deleted, and their names render at 1.87:1.
  • ApexCharts still draws fractional y-axis ticks on discrete metrics.

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.
@danielchalmers danielchalmers changed the title Rebuild the app around one Material 3 Expressive shape, color and motion system Rebuild around one Material 3 Expressive shape, color and motion system Jul 27, 2026
@danielchalmers danielchalmers changed the title Rebuild around one Material 3 Expressive shape, color and motion system One Material 3 Expressive shape, color and motion system Jul 27, 2026
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.
@danielchalmers
danielchalmers merged commit 08eeb48 into main Jul 27, 2026
1 check passed
@danielchalmers
danielchalmers deleted the claude/light-theme-md3-contrast-25abc1 branch July 27, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant