Repository navigation
feat(theme): Add medium and high contrast theme - #5100
hristototov wants to merge 10 commits into
Conversation
…into hristototov/medium-and-high-contrast-theme
| /** Only `standard` uses the reference palette steps. The other levels need | ||
| * tones that are not in the palette, so they live in `./contrastSchemes`. */ |
There was a problem hiding this comment.
These don't match MD3's published contrast schemes. On the color roles page, light/high primary is md.ref.palette.primary20 (#381E72), but this ships #312259; secondary, outline, outline-variant and on-surface-variant are off too, and surface-container isn't meant to change with contrast at all. Every tone MD3 asks for is already a key of ref/palette.ts - could roleToTone just gain medium and high maps, the way the old // extend with 'medium' | 'high' when those ship comment planned?
There was a problem hiding this comment.
updated - I've removed the generator and hand-written the medium and high maps as you suggested.
| return { | ||
| ...base, | ||
| ...props.theme, | ||
| contrast: level, |
There was a problem hiding this comment.
Passing a theme that has its own colors silently overrides the contrast scheme, but theme.contrast still reports the level you asked for - so useTheme().contrast says "high" while the colours on screen are standard. Worth reconciling, given the docs tell people to read the level back off the theme.
There was a problem hiding this comment.
Good catch. The behaviour is intentional but was undocumented, which is the actual problem: contrast records which scheme was selected, and anything passed via theme.colors is layered on top, so an override survives a contrast change.
I've added code comments making that explicit.
| ? DarkTheme | ||
| : LightTheme; | ||
| ? getDynamicTheme(isDarkMode, contrast) | ||
| : createTheme({ dark: isDarkMode, contrast }); |
There was a problem hiding this comment.
This builds a new theme object on every render, so the provider rebuilds the theme and every themed component re-renders with it. getTheme(isDarkMode, contrast) returns the cached one worth using that here?
|
And we've got conflicts |
…into hristototov/medium-and-high-contrast-theme # Conflicts: # src/core/PaperProvider.tsx # src/index.tsx
| primaryFixedDim: 'primary80', | ||
| onPrimaryFixed: 'primary10', | ||
| onPrimaryFixedVariant: 'primary30', |
There was a problem hiding this comment.
I get a contrast ratio of 5.47:1 for onPrimaryFixedVariant (#4F378B) on primaryFixedDim (#D0BCFF), below the documented 7:1 target for high contrast. The dark/high mapping at lines 311-313 uses the same values. Could we adjust this pair and include it in the contrast assertions? CONTRAST_PAIRS currently checks onPrimaryFixed on primaryFixed, so it misses this combination.
| secondaryFixedDim: 'secondary80', | ||
| onSecondaryFixed: 'secondary10', | ||
| onSecondaryFixedVariant: 'secondary30', |
There was a problem hiding this comment.
I get 5.45:1 for onSecondaryFixedVariant (#4A4458) on secondaryFixedDim (#CCC2DC), below the documented 7:1 high-contrast target. The dark/high mapping at lines 315-317 has the same issue. Could we adjust this pair and cover it in the contrast assertions as well?
| tertiaryFixedDim: 'tertiary80', | ||
| onTertiaryFixed: 'tertiary10', | ||
| onTertiaryFixedVariant: 'tertiary30', |
There was a problem hiding this comment.
I get 5.49:1 for onTertiaryFixedVariant (#633B48) on tertiaryFixedDim (#EFB8C8), below the documented 7:1 high-contrast target. The dark/high mapping at lines 319-321 has the same issue. Could we adjust this pair and cover it in the contrast assertions as well? The test that requires all fixed colors to stay unchanged across contrast levels would also need to be revised.
satya164
left a comment
There was a problem hiding this comment.
Any additional themes should just be exported theme objects, e.g.:
MediumContrastLightThemeMediumContrastDarkThemeHighContrastLightThemeHighContrastDarkTheme
Similarly there can be variants for dynamic themes.
It's a much simpler concept since we already export themes and accept theme prop, and there's no need for additional helpers and utilities. "Building" a theme object with createTheme({ dark: true, contrast: 'high' }) instead of just importing HighContrastDarkTheme is unnecessary indirection.
…into hristototov/medium-and-high-contrast-theme # Conflicts: # src/core/PaperProvider.tsx # src/theme/provider.tsx
Done. Removed Now exported:
|
…into hristototov/medium-and-high-contrast-theme
…into hristototov/medium-and-high-contrast-theme
|
Review by Claude iOS
Android
Web |
satya164
left a comment
There was a problem hiding this comment.
Review by Claude
- The "44" on the active Inbox item in the example drawer drops to 1.70:1 (dark, high) and 2.26:1 (light, high) on every platform.
example/src/DrawerItems.tsxline 28 renders it without thecolorthatDrawer.Itempasses toright. Details and screenshots: #5100 (comment) - The PR description is out of date. It still describes the
contrastprop onPaperProvider,scripts/generate-contrast-tokens.tsandPaperProvider.test.tsx, which the later commits removed. The tone table lists generated values (for example 26.7 for light mediumprimary) that the hand-written maps no longer use (tone 30). (via Claude, verify if it makes sense)
| /** | ||
| * Android exposes no contrast adjusted version of its system palette, so the | ||
| * raised levels fall back to the static schemes. Using the standard contrast | ||
| * system colors there would quietly lower the contrast the user asked for. | ||
| */ | ||
| export { | ||
| MediumContrastLightTheme as MediumContrastDynamicLightTheme, | ||
| HighContrastLightTheme as HighContrastDynamicLightTheme, | ||
| } from './LightTheme'; | ||
| export { | ||
| MediumContrastDarkTheme as MediumContrastDynamicDarkTheme, | ||
| HighContrastDarkTheme as HighContrastDynamicDarkTheme, | ||
| } from './DarkTheme'; |
There was a problem hiding this comment.
These four exports are aliases of the static themes on every platform, so HighContrastDynamicLightTheme is the same object as HighContrastLightTheme. An app that picks the dynamic variant loses the wallpaper colors and gets the baseline purple scheme with no signal. In the example app, "Use Dynamic Theme" on plus High shows the static scheme:
The comment is also not accurate for API 34+. There, buildDynamicColors reads the system_*_light / system_*_dark role resources, and Android builds those from the contrast level the user picks in the system Color contrast setting (UiModeManager.getContrast(), API 34). So DynamicLightTheme already follows the user's contrast choice there, and an app cannot ask the system for a different level.
Suggest removing the four *ContrastDynamic* exports here, in DynamicTheme.tsx and in schemes/index.ts, and changing the "Contrast and dynamic colors" docs section to say that DynamicLightTheme and DynamicDarkTheme follow the system contrast setting on API 34+, and that apps that need a fixed raised level use the static contrast themes. (via Claude, verify if it makes sense)
| shadow: 'neutral0', | ||
| scrim: 'neutral0', | ||
| }, | ||
| medium: { |
There was a problem hiding this comment.
The hand-written medium and high maps differ from Material Color Utilities, the library that Material Theme Builder and Android dynamic color use. I compared the tones with SchemeTonalSpot from @material/material-color-utilities@0.3.0, seed #6750A4, contrast level 0.5 and 1.0. At contrast 0 it matches the standard maps here except a few on*Container roles, so the comparison holds. Tones that differ by more than 3:
| scheme | role | PR | MCU |
|---|---|---|---|
| light medium | primary / secondary / tertiary / error |
30 | 23 |
| light medium | *Container |
40 | 46 |
| light medium | onSurface |
10 | 5 |
| light medium | onSurfaceVariant |
30 | 23 |
| light medium | outlineVariant |
60 | 46 |
| light medium | surfaceContainerHighest |
90 | 84 |
| light high | onSurfaceVariant |
20 | 0 |
| light high | outlineVariant |
40 | 31 |
| light high | surfaceContainer / surfaceContainerHighest |
94 / 90 | 90 / 80 |
| light medium / high | primaryFixed |
90 | 46 / 31 |
| light medium / high | onPrimaryFixed |
10 | 100 |
| dark medium | *Container |
70 | 60 |
| dark high | outline / outlineVariant |
80 / 60 | 95 / 79 |
| dark high | surfaceContainerHighest |
22 | 30 |
MCU changes the surface containers, surfaceDim / surfaceBright and the *Fixed roles with contrast, and the doc comment above and the "keeps the fixed surfaces the same at every contrast level" test say the opposite. Apps that also use Theme Builder output or Android colors get different values for the same level. Could the tones come from MCU (or a Theme Builder export for the baseline seed) so they match the spec, with the tests adjusted to match? (via Claude, verify if it makes sense)
There was a problem hiding this comment.
Confirmed against MCU 0.3.0, but these maps follow @JKobrynski's earlier request to use palette tones from the MD3 roles page, which differ from MCU. @JKobrynski @satya164, which source should we follow?
| import type { Theme } from '../types'; | ||
|
|
||
| type ThemeDefaults = Omit<Theme, 'dark' | 'colors'>; | ||
| type ThemeDefaults = Omit<Theme, 'dark' | 'colors' | 'contrast'>; |
There was a problem hiding this comment.
Theme has no contrast field after the latest commits, so 'contrast' in this Omit does nothing. Revert it:
| type ThemeDefaults = Omit<Theme, 'dark' | 'colors' | 'contrast'>; | |
| type ThemeDefaults = Omit<Theme, 'dark' | 'colors'>; |
(via Claude, verify if it makes sense)
| flexDirection: 'column', | ||
| alignItems: 'stretch', | ||
| gap: 12, | ||
| paddingHorizontal: 28, |
There was a problem hiding this comment.
On an iPhone 17 the drawer is too narrow for three segments plus 28pt padding on both sides, so "Standard" shows as "Stand...":
With 16pt padding all three labels fit. To keep the "Contrast" label aligned with the other rows, give that Text paddingHorizontal: 12.
| paddingHorizontal: 28, | |
| paddingHorizontal: 16, |
(via Claude, verify if it makes sense)
…into hristototov/medium-and-high-contrast-theme
Fixed. The Inbox "44" now uses the color that Drawer.Item passes to right, the same way "Starred" does, so it stays readable at every contrast level.
updated |







Motivation
Paper only ships the MD3
standardcontrast level. MD3 defines two raised levels,mediumandhigh, for accessibility. They increase the contrast between text and its background so the UI stays readable for people with low vision and in bright light. Without them, apps built with Paper have no supported way to meet the raised contrast ratios.This adds four exported themes, used like the existing ones:
MediumContrastLightThemeandMediumContrastDarkThemeHighContrastLightThemeandHighContrastDarkThemeLightThemeandDarkThemeare unchanged.There are no dynamic contrast variants. On Android 14 (API 34) and newer,
DynamicLightThemeandDynamicDarkThemealready follow the system Color contrast setting. Apps that need a fixed raised level use the static contrast themes.The schemes map each color role to a tone of the reference palette. Tones for the baseline
#6750A4seed:primaryprimaryContaineronSurfaceVariantRelated issue
Closes #5092
Screenshots / Videos
android-contrast.mov
ios-contrast.mov
Test plan
In the example app, open the drawer and find Contrast under Preferences.