From 83648aadfb978ccdfa672d9bd7a07ae9d7a740ca Mon Sep 17 00:00:00 2001 From: Hristo Totov Date: Thu, 3 Sep 2026 12:53:30 +0300 Subject: [PATCH 1/3] feat(theme): add medium and high contrast levels --- docs/6.x/docs/guides/theming.mdx | 57 ++++ example/src/DrawerItems.tsx | 23 ++ example/src/PreferencesContext.tsx | 4 +- example/src/index.tsx | 34 ++- example/utils/themes.ts | 62 ++-- package.json | 2 + scripts/generate-contrast-tokens.ts | 207 ++++++++++++++ .../__snapshots__/ListSection.test.tsx.snap | 3 + src/core/PaperProvider.tsx | 13 +- src/core/__tests__/PaperProvider.test.tsx | 68 ++++- src/core/__tests__/theming.test.tsx | 23 +- src/index.tsx | 8 +- src/theme/__tests__/contrast.test.ts | 132 +++++++++ src/theme/provider.tsx | 28 +- src/theme/schemes/DarkTheme.tsx | 12 +- src/theme/schemes/DynamicTheme.android.tsx | 22 +- src/theme/schemes/DynamicTheme.tsx | 11 + src/theme/schemes/LightTheme.tsx | 12 +- src/theme/schemes/base.ts | 2 +- src/theme/schemes/createTheme.ts | 28 ++ src/theme/schemes/index.ts | 3 + src/theme/tokens/sys/color.ts | 44 ++- src/theme/tokens/sys/contrastSchemes.ts | 264 ++++++++++++++++++ src/theme/types/theme.ts | 3 + yarn.lock | 8 + 25 files changed, 976 insertions(+), 97 deletions(-) create mode 100644 scripts/generate-contrast-tokens.ts create mode 100644 src/theme/__tests__/contrast.test.ts create mode 100644 src/theme/schemes/createTheme.ts create mode 100644 src/theme/tokens/sys/contrastSchemes.ts diff --git a/docs/6.x/docs/guides/theming.mdx b/docs/6.x/docs/guides/theming.mdx index 261fe128be..ffb825aa8b 100644 --- a/docs/6.x/docs/guides/theming.mdx +++ b/docs/6.x/docs/guides/theming.mdx @@ -65,6 +65,7 @@ You can change the theme prop dynamically and all the components will automatica A theme usually contains the following properties: - `dark` (`boolean`): whether this is a dark theme or light theme. +- `contrast` (`'standard' | 'medium' | 'high'`): the active MD3 contrast level (see [Contrast levels](#contrast-levels)). - `version`: Material You (MD3); kept for compatibility and normalized to `3` by `PaperProvider` - `mode` (`'adaptive' | 'exact'`): color mode for dark theme (See [Dark Theme](#dark-theme)). - `roundness` (`number`): roundness of common elements, such as buttons. @@ -244,6 +245,62 @@ export default function Main() { } ``` +## Contrast levels + +Material Design 3 defines three contrast levels - `standard`, `medium` and `high`. The higher levels increase the contrast between foreground and background roles, which helps users with low vision and improves readability in bright environments. + +Set the level with the `contrast` prop on `PaperProvider`. It defaults to `standard`, so existing apps are unaffected. + +```js +import * as React from 'react'; +import { PaperProvider } from 'react-native-paper'; + +export default function Main() { + return ( + + + + ); +} +``` + +Use the `contrast` prop rather than passing a pre-built theme: `PaperProvider` only follows the system light/dark setting while no `theme` prop is given, so selecting contrast through `theme` would also opt you out of automatic dark mode. + +The `medium` and `high` schemes meet the WCAG contrast ratios of 4.5:1 and 7:1 respectively for every foreground/background role pair. + +The active level is readable from the theme: + +```js +const { contrast } = useTheme(); +``` + +To build a theme object directly, for example to hand to `adaptNavigationTheme`, use `createTheme` or `getTheme`: + +```js +import { createTheme, getTheme } from 'react-native-paper'; + +const highContrastDark = createTheme({ dark: true, contrast: 'high' }); +const sameThing = getTheme(true, 'high'); +``` + +### Contrast and dynamic colors + +Android does not expose a contrast-adjusted version of its system palette. Applying the standard-contrast system colors at a raised contrast level would quietly undercut the level you asked for, so at `medium` and `high` the dynamic palette is skipped in favour of the contrast-correct scheme. + +`getDynamicTheme` applies this rule for you, and `isDynamicColorSupportedAtContrast` reports whether dynamic colors will actually be used: + +```js +import { + getDynamicTheme, + isDynamicColorSupportedAtContrast, +} from 'react-native-paper'; + +// Falls back to the high-contrast scheme, dynamic colors included only at 'standard'. +const theme = getDynamicTheme(isDarkMode, 'high'); + +isDynamicColorSupportedAtContrast('high'); // false +``` + ## Adapting React Navigation theme The `adaptNavigationTheme` function takes an existing React Navigation theme and returns a React Navigation theme using the colors from Material Design 3. This theme can be passed to `NavigationContainer` so that React Navigation's UI elements have the same color scheme as Paper. diff --git a/example/src/DrawerItems.tsx b/example/src/DrawerItems.tsx index 94afa3136c..3101f58634 100644 --- a/example/src/DrawerItems.tsx +++ b/example/src/DrawerItems.tsx @@ -11,6 +11,7 @@ import { Drawer, Palette, Portal, + SegmentedButtons, Switch, Text, TouchableRipple, @@ -105,9 +106,11 @@ function DrawerItems() { toggleCollapsed, toggleCustomFont, toggleRippleEffect, + setContrast, customFontLoaded, rippleEffectEnabled, collapsed, + contrast, rtl: isRTL, theme: { dark: isDarkTheme }, shouldUseDynamicTheme, @@ -192,6 +195,20 @@ function DrawerItems() { + + Contrast + setContrast(value)} + density="small" + buttons={[ + { value: 'standard', label: 'Standard' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + ]} + /> + + RTL @@ -278,6 +295,12 @@ const styles = StyleSheet.create({ height: 56, paddingHorizontal: 28, }, + contrastPreference: { + flexDirection: 'column', + alignItems: 'stretch', + gap: 12, + paddingHorizontal: 28, + }, badge: { alignSelf: 'center', }, diff --git a/example/src/PreferencesContext.tsx b/example/src/PreferencesContext.tsx index b5e381ae05..875fa8e4e9 100644 --- a/example/src/PreferencesContext.tsx +++ b/example/src/PreferencesContext.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import type { Theme } from 'react-native-paper'; +import type { ContrastLevel, Theme } from 'react-native-paper'; export const PreferencesContext = React.createContext<{ toggleTheme: () => void; @@ -9,7 +9,9 @@ export const PreferencesContext = React.createContext<{ toggleCustomFont: () => void; toggleRippleEffect: () => void; toggleShouldUseDynamicTheme?: () => void; + setContrast: (contrast: ContrastLevel) => void; theme: Theme; + contrast: ContrastLevel; rtl: boolean; collapsed: boolean; customFontLoaded: boolean; diff --git a/example/src/index.tsx b/example/src/index.tsx index afa3941044..47ad19db06 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -14,10 +14,9 @@ import { StatusBar } from 'expo-status-bar'; import * as Updates from 'expo-updates'; import { PaperProvider, - DarkTheme, - LightTheme, - DynamicLightTheme, - DynamicDarkTheme, + createTheme, + getDynamicTheme, + type ContrastLevel, } from 'react-native-paper'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -26,8 +25,7 @@ import { PreferencesContext } from './PreferencesContext'; import App from './RootNavigator'; import { dynamicThemeSupported } from '../utils'; import { - CombinedDarkTheme, - CombinedDefaultTheme, + createCombinedTheme, createConfiguredFontNavigationTheme, createConfiguredFontTheme, } from '../utils/themes'; @@ -98,15 +96,12 @@ export default function PaperExample() { const [collapsed, setCollapsed] = React.useState(false); const [customFontLoaded, setCustomFont] = React.useState(false); const [rippleEffectEnabled, setRippleEffectEnabled] = React.useState(true); + const [contrast, setContrast] = React.useState('standard'); const theme = dynamicThemeSupported && shouldUseDynamicTheme - ? isDarkMode - ? DynamicDarkTheme - : DynamicLightTheme - : isDarkMode - ? DarkTheme - : LightTheme; + ? getDynamicTheme(isDarkMode, contrast) + : createTheme({ dark: isDarkMode, contrast }); const direction = rtl ? 'rtl' : 'ltr'; @@ -122,6 +117,13 @@ export default function PaperExample() { if (typeof preferences.rtl === 'boolean') { setRtl(preferences.rtl); } + + if ( + preferences.contrast === 'medium' || + preferences.contrast === 'high' + ) { + setContrast(preferences.contrast); + } } } catch (e) { // ignore error @@ -145,6 +147,7 @@ export default function PaperExample() { JSON.stringify({ theme: isDarkMode ? 'dark' : 'light', rtl, + contrast, }) ); } catch (e) { @@ -165,7 +168,7 @@ export default function PaperExample() { }; void savePrefs(); - }, [direction, isDarkMode, isReady, rtl]); + }, [contrast, direction, isDarkMode, isReady, rtl]); const preferences = React.useMemo( () => ({ @@ -176,9 +179,11 @@ export default function PaperExample() { toggleCollapsed: () => setCollapsed((oldValue) => !oldValue), toggleCustomFont: () => setCustomFont((oldValue) => !oldValue), toggleRippleEffect: () => setRippleEffectEnabled((oldValue) => !oldValue), + setContrast, customFontLoaded, rippleEffectEnabled, shouldUseDynamicTheme, + contrast, theme, collapsed, rtl, @@ -187,6 +192,7 @@ export default function PaperExample() { rtl, theme, collapsed, + contrast, customFontLoaded, shouldUseDynamicTheme, rippleEffectEnabled, @@ -197,7 +203,7 @@ export default function PaperExample() { return null; } - const combinedTheme = isDarkMode ? CombinedDarkTheme : CombinedDefaultTheme; + const combinedTheme = createCombinedTheme(theme, isDarkMode); const configuredFontTheme = createConfiguredFontTheme(combinedTheme); const configuredFontNavigationTheme = createConfiguredFontNavigationTheme(combinedTheme); diff --git a/example/utils/themes.ts b/example/utils/themes.ts index 22fda309e7..6ce2ce059f 100644 --- a/example/utils/themes.ts +++ b/example/utils/themes.ts @@ -3,44 +3,38 @@ import { DefaultTheme as NavigationDefaultTheme, } from '@react-navigation/native'; import type { Theme as ReactNavigationTheme } from '@react-navigation/native'; -import { - adaptNavigationTheme, - DarkTheme, - LightTheme, - configureFonts, -} from 'react-native-paper'; +import { adaptNavigationTheme, configureFonts } from 'react-native-paper'; import type { Theme } from 'react-native-paper'; -const { LightTheme: NavLightTheme, DarkTheme: NavDarkTheme } = - adaptNavigationTheme({ - reactNavigationLight: NavigationDefaultTheme, - reactNavigationDark: NavigationDarkTheme, - }); +/** + * Merges the React Navigation theme into a Paper theme. + * + * The Paper theme is passed in, and also given to `adaptNavigationTheme`, so + * that the selected contrast level is kept. + */ +export const createCombinedTheme = (paperTheme: Theme, isDark: boolean) => { + const { LightTheme: NavLightTheme, DarkTheme: NavDarkTheme } = + adaptNavigationTheme({ + reactNavigationLight: NavigationDefaultTheme, + reactNavigationDark: NavigationDarkTheme, + materialLight: isDark ? undefined : paperTheme, + materialDark: isDark ? paperTheme : undefined, + }); -export const CombinedDefaultTheme = { - ...LightTheme, - ...NavLightTheme, - colors: { - ...LightTheme.colors, - ...NavLightTheme.colors, - }, - fonts: { - ...LightTheme.fonts, - ...NavLightTheme.fonts, - }, -}; + const navTheme = isDark ? NavDarkTheme : NavLightTheme; -export const CombinedDarkTheme = { - ...DarkTheme, - ...NavDarkTheme, - colors: { - ...DarkTheme.colors, - ...NavDarkTheme.colors, - }, - fonts: { - ...DarkTheme.fonts, - ...NavDarkTheme.fonts, - }, + return { + ...paperTheme, + ...navTheme, + colors: { + ...paperTheme.colors, + ...navTheme.colors, + }, + fonts: { + ...paperTheme.fonts, + ...navTheme.fonts, + }, + }; }; export const createConfiguredFontTheme = ( diff --git a/package.json b/package.json index 25ca91fb73..62e853a4f4 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test": "jest", "prepack": "bob build", "generate-mappings": "node ./scripts/generate-mappings.ts", + "generate-contrast-tokens": "node ./scripts/generate-contrast-tokens.ts", "release": "release-it --only-version", "docs": "yarn --cwd docs", "example": "yarn --cwd example" @@ -61,6 +62,7 @@ "@commitlint/config-conventional": "^8.3.4", "@eslint/js": "9.39.4", "@jest/globals": "^29.7.0", + "@material/material-color-utilities": "0.3.0", "@react-native-vector-icons/material-design-icons": "^12.0.0", "@react-native/babel-preset": "^0.85.3", "@react-native/jest-preset": "^0.85.3", diff --git a/scripts/generate-contrast-tokens.ts b/scripts/generate-contrast-tokens.ts new file mode 100644 index 0000000000..dd5414a4c7 --- /dev/null +++ b/scripts/generate-contrast-tokens.ts @@ -0,0 +1,207 @@ +/** + * Generates the MD3 medium and high contrast schemes into + * `src/theme/tokens/sys/contrastSchemes.ts`. + * + * MD3 does not pick a different palette step for medium and high contrast. + * For each role it looks for the tone that hits a target contrast ratio + * against that role's background. The tones it finds are usually not whole numbers. + * For example `primary` at light/high lands on tone 13.3, so it cannot be + * written as a key of `ref/palette.ts`. + * + * Run with `yarn generate-contrast-tokens`. + */ +import { + Hct, + MaterialDynamicColors, + SchemeTonalSpot, + argbFromHex, + blueFromArgb, + greenFromArgb, + redFromArgb, +} from '@material/material-color-utilities'; +import { writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { ElevationColors, ThemeColors } from '../src/theme/types'; + +type Role = Exclude; + +type ElevationKey = Exclude; + +const SEED = '#6750A4'; + +const MODES = [ + ['light', false], + ['dark', true], +] as const; + +const CONTRASTS = [ + ['medium', 0.5], + ['high', 1.0], +] as const; + +// The roles to generate, in the order they are written to the output file. +const ROLES = [ + 'primary', + 'primaryContainer', + 'secondary', + 'secondaryContainer', + 'tertiary', + 'tertiaryContainer', + 'surface', + 'surfaceDim', + 'surfaceBright', + 'surfaceContainerLowest', + 'surfaceContainerLow', + 'surfaceContainer', + 'surfaceContainerHigh', + 'surfaceContainerHighest', + 'surfaceVariant', + 'background', + 'error', + 'errorContainer', + 'onPrimary', + 'onPrimaryContainer', + 'onSecondary', + 'onSecondaryContainer', + 'onTertiary', + 'onTertiaryContainer', + 'onSurface', + 'onSurfaceVariant', + 'onError', + 'onErrorContainer', + 'onBackground', + 'outline', + 'outlineVariant', + 'inverseSurface', + 'inverseOnSurface', + 'inversePrimary', + 'primaryFixed', + 'primaryFixedDim', + 'onPrimaryFixed', + 'onPrimaryFixedVariant', + 'secondaryFixed', + 'secondaryFixedDim', + 'onSecondaryFixed', + 'onSecondaryFixedVariant', + 'tertiaryFixed', + 'tertiaryFixedDim', + 'onTertiaryFixed', + 'onTertiaryFixedVariant', + 'shadow', + 'scrim', +] as const satisfies readonly Role[]; + +// Fails to compile if a role in `ThemeColors` is missing from `ROLES`. +type MissingRoles = Exclude; + +type AssertNoMissingRoles = T; + +export type _RolesAreComplete = AssertNoMissingRoles; + +/** + * Tonal elevation surfaces. MD3 maps these onto the surface container roles. + * `level0` is always transparent, so it is not generated. + */ +const ELEVATION_ROLES: Record = { + level1: 'surfaceContainerLow', + level2: 'surfaceContainer', + level3: 'surfaceContainerHigh', + level4: 'surfaceContainerHigh', + level5: 'surfaceContainerHighest', +}; + +/** Matches the `rgba(r, g, b, 1)` format used throughout the theme tokens. */ +const toRgbaString = (argb: number) => + `rgba(${redFromArgb(argb)}, ${greenFromArgb(argb)}, ${blueFromArgb(argb)}, 1)`; + +const lookupRole = (role: string): unknown => + Object.entries(MaterialDynamicColors).find(([key]) => key === role)?.[1]; + +const resolveRole = (scheme: SchemeTonalSpot, role: string) => { + const dynamicColor = lookupRole(role); + + if ( + dynamicColor == null || + typeof dynamicColor !== 'object' || + !('getArgb' in dynamicColor) || + typeof dynamicColor.getArgb !== 'function' + ) { + throw new Error( + `@material/material-color-utilities does not expose the "${role}" role. ` + + `Check the pinned version, role coverage differs between releases.` + ); + } + + return toRgbaString(dynamicColor.getArgb(scheme)); +}; + +const seed = Hct.fromInt(argbFromHex(SEED)); + +const schemes = MODES.map(([mode, isDark]) => { + const contrasts = CONTRASTS.map(([contrast, level]) => { + const scheme = new SchemeTonalSpot(seed, isDark, level); + + const roles = ROLES.map( + (role) => ` ${role}: '${resolveRole(scheme, role)}',` + ).join('\n'); + + const elevation = Object.entries(ELEVATION_ROLES) + .map(([key, role]) => ` ${key}: '${resolveRole(scheme, role)}',`) + .join('\n'); + + return [ + ` ${contrast}: {`, + ` roles: {`, + roles, + ` },`, + ` elevation: {`, + elevation, + ` },`, + ` },`, + ].join('\n'); + }).join('\n'); + + return [` ${mode}: {`, contrasts, ` },`].join('\n'); +}).join('\n'); + +const output = `/** + * GENERATED by scripts/generate-contrast-tokens.ts, do not edit by hand. + * Run \`yarn generate-contrast-tokens\` to regenerate. + * + * MD3 medium and high contrast schemes, seeded from ${SEED}. + * + */ +import type { ContrastLevel, ElevationColors, ThemeColors } from '../../types'; + +type GeneratedContrast = Exclude; + +type GeneratedScheme = { + roles: Record< + Exclude, + string + >; + elevation: Record, string>; +}; + +export const contrastSchemes: Record< + 'light' | 'dark', + Record +> = { +${schemes} +} as const; +`; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const outputPath = resolve( + scriptDir, + '..', + 'src/theme/tokens/sys/contrastSchemes.ts' +); + +writeFileSync(outputPath, output); + +console.log( + `Generated ${ROLES.length} roles x ${MODES.length} modes x ${CONTRASTS.length} contrast levels -> ${outputPath}` +); diff --git a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap index 7e7dcc158c..f42d41e0ef 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -74,6 +74,7 @@ exports[`renders list section with custom title style 1`] = ` "tertiaryFixed": "rgba(255, 216, 228, 1)", "tertiaryFixedDim": "rgba(239, 184, 200, 1)", }, + "contrast": "standard", "dark": false, "elevation": { "level0": 0, @@ -836,6 +837,7 @@ exports[`renders list section with subheader 1`] = ` "tertiaryFixed": "rgba(255, 216, 228, 1)", "tertiaryFixedDim": "rgba(239, 184, 200, 1)", }, + "contrast": "standard", "dark": false, "elevation": { "level0": 0, @@ -1596,6 +1598,7 @@ exports[`renders list section without subheader 1`] = ` "tertiaryFixed": "rgba(255, 216, 228, 1)", "tertiaryFixedDim": "rgba(239, 184, 200, 1)", }, + "contrast": "standard", "dark": false, "elevation": { "level0": 0, diff --git a/src/core/PaperProvider.tsx b/src/core/PaperProvider.tsx index 5149efcd6f..14df4b0f42 100644 --- a/src/core/PaperProvider.tsx +++ b/src/core/PaperProvider.tsx @@ -4,7 +4,7 @@ import { getDefaultDirection, LocaleProvider, type Direction } from './locale'; import SafeAreaProviderCompat from './SafeAreaProviderCompat'; import { Provider as SettingsProvider } from './settings'; import type { Settings } from './settings'; -import { defaultThemes, ThemeProvider } from './theming'; +import { getTheme, ThemeProvider } from './theming'; import { useResolvedReduceMotion, type ReduceMotionPreference, @@ -13,7 +13,7 @@ import { useSystemColorScheme } from './useSystemColorScheme'; import MaterialCommunityIcon from '../components/MaterialCommunityIcon'; import PortalHost from '../components/Portal/PortalHost'; import { ReduceMotionContext } from '../theme/accessibility/ReduceMotionContext'; -import type { ThemeProp } from '../types'; +import type { ContrastLevel, ThemeProp } from '../types'; export type Props = { children: React.ReactNode; @@ -21,17 +21,19 @@ export type Props = { settings?: Settings; direction?: Direction; reduceMotion?: ReduceMotionPreference; + contrast?: ContrastLevel; }; const PaperProvider = (props: Props) => { - const { reduceMotion = 'auto' } = props; + const { reduceMotion = 'auto', contrast } = props; const colorScheme = useSystemColorScheme(!props.theme); const resolvedReduceMotion = useResolvedReduceMotion(reduceMotion); const theme = React.useMemo(() => { const isDark = props.theme?.dark ?? colorScheme === 'dark'; - const base = defaultThemes[isDark ? 'dark' : 'light']; + const level = contrast ?? props.theme?.contrast ?? 'standard'; + const base = getTheme(isDark, level); const scale = resolvedReduceMotion ? 0 : (props.theme?.animation?.scale ?? 1); @@ -39,10 +41,11 @@ const PaperProvider = (props: Props) => { return { ...base, ...props.theme, + contrast: level, colors: { ...base.colors, ...props.theme?.colors }, animation: { ...props.theme?.animation, scale }, }; - }, [colorScheme, props.theme, resolvedReduceMotion]); + }, [colorScheme, contrast, props.theme, resolvedReduceMotion]); const { children, settings } = props; diff --git a/src/core/__tests__/PaperProvider.test.tsx b/src/core/__tests__/PaperProvider.test.tsx index 28ac30e327..43ea7c7175 100644 --- a/src/core/__tests__/PaperProvider.test.tsx +++ b/src/core/__tests__/PaperProvider.test.tsx @@ -14,7 +14,7 @@ import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { DarkTheme, DynamicLightTheme, LightTheme } from '../../theme/schemes'; import type { ThemeProp } from '../../types'; import PaperProvider from '../PaperProvider'; -import { useTheme } from '../theming'; +import { getTheme, useTheme } from '../theming'; declare module 'react-native' { interface AccessibilityInfoStatic { @@ -329,4 +329,70 @@ describe('PaperProvider', () => { customTheme ); }); + + it('applies the contrast prop without a theme prop', async () => { + mockAppearance(); + await render( + + + + ); + + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + const theme = screen.getByTestId('provider-child-view').props.theme; + + expect(theme).toStrictEqual(getTheme(false, 'high')); + expect(theme.contrast).toBe('high'); + expect(theme.colors.primary).not.toBe(LightTheme.colors.primary); + }); + + it('keeps following the system color scheme when only contrast is set', async () => { + mockAppearance(); + await render( + + + + ); + + expect( + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + screen.getByTestId('provider-child-view').props.theme + ).toStrictEqual(getTheme(false, 'medium')); + + await act(() => Appearance.__internalListeners[0]({ colorScheme: 'dark' })); + + expect( + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + screen.getByTestId('provider-child-view').props.theme + ).toStrictEqual(getTheme(true, 'medium')); + }); + + it('defaults to standard contrast', async () => { + mockAppearance(); + await render(createProvider()); + + expect( + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + screen.getByTestId('provider-child-view').props.theme.contrast + ).toBe('standard'); + }); + + it('lets the contrast prop win over a theme declaring its own level', async () => { + mockAppearance(); + await render( + + + + ); + + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + const theme = screen.getByTestId('provider-child-view').props.theme; + + expect(theme.contrast).toBe('standard'); + expect(theme.colors.primary).toBe(LightTheme.colors.primary); + }); }); diff --git a/src/core/__tests__/theming.test.tsx b/src/core/__tests__/theming.test.tsx index cb61aef20d..b5b8cad0c3 100644 --- a/src/core/__tests__/theming.test.tsx +++ b/src/core/__tests__/theming.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it } from '@jest/globals'; import { DarkTheme, LightTheme } from '../../theme/schemes'; -import { adaptNavigationTheme } from '../theming'; +import { adaptNavigationTheme, getTheme } from '../theming'; const NavigationLightTheme = { dark: false, @@ -273,4 +273,25 @@ describe('adaptNavigationTheme', () => { expect(navLight).not.toHaveProperty('fonts'); expect(navDark).not.toHaveProperty('fonts'); }); + + it('adapts the colors of a raised-contrast material theme', () => { + const materialLight = getTheme(false, 'high'); + const materialDark = getTheme(true, 'high'); + + const { LightTheme: navLight, DarkTheme: navDark } = adaptNavigationTheme({ + reactNavigationLight: NavigationLightTheme, + reactNavigationDark: NavigationDarkTheme, + materialLight, + materialDark, + }); + + // Apps spread the navigation colors over the Paper theme, so these + // must match the contrast level that was asked for. + expect(navLight.colors.primary).toBe(materialLight.colors.primary); + expect(navLight.colors.text).toBe(materialLight.colors.onSurface); + expect(navDark.colors.primary).toBe(materialDark.colors.primary); + + expect(navLight.colors.primary).not.toBe(LightTheme.colors.primary); + expect(navDark.colors.primary).not.toBe(DarkTheme.colors.primary); + }); }); diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..9916276c41 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,7 @@ export { withTheme, ThemeProvider, adaptNavigationTheme, + getTheme, } from './core/theming'; export { useLocale, LocaleProvider } from './core/locale'; @@ -147,4 +148,9 @@ export type { Props as SegmentedButtonsProps } from './components/SegmentedButto export type { Props as ListImageProps } from './components/List/ListImage'; export type { Props as TooltipProps } from './components/Tooltip/Tooltip'; -export { type TypescaleKey, type Theme, type Elevation } from './types'; +export { + type TypescaleKey, + type Theme, + type Elevation, + type ContrastLevel, +} from './types'; diff --git a/src/theme/__tests__/contrast.test.ts b/src/theme/__tests__/contrast.test.ts new file mode 100644 index 0000000000..3b3fbb92ac --- /dev/null +++ b/src/theme/__tests__/contrast.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from '@jest/globals'; +import color from 'color'; + +import { createTheme } from '../schemes/createTheme'; +import { DarkTheme } from '../schemes/DarkTheme'; +import { LightTheme } from '../schemes/LightTheme'; +import { palette } from '../tokens/ref/palette'; +import { buildScheme } from '../tokens/sys/color'; +import type { ContrastLevel, ThemeColors } from '../types'; + +const MODES = ['light', 'dark'] as const; +const NON_STANDARD = ['medium', 'high'] as const satisfies ContrastLevel[]; + +/** + * Text and background role pairs that MD3 requires to be readable. + * @see https://m3.material.io/styles/color/roles + */ +const CONTRAST_PAIRS: [keyof ThemeColors, keyof ThemeColors][] = [ + ['onPrimary', 'primary'], + ['onPrimaryContainer', 'primaryContainer'], + ['onSecondary', 'secondary'], + ['onSecondaryContainer', 'secondaryContainer'], + ['onTertiary', 'tertiary'], + ['onTertiaryContainer', 'tertiaryContainer'], + ['onError', 'error'], + ['onErrorContainer', 'errorContainer'], + ['onSurface', 'surface'], + ['onSurfaceVariant', 'surfaceVariant'], + ['onBackground', 'background'], + ['onSurface', 'surfaceContainer'], + ['onSurface', 'surfaceContainerHighest'], + ['inverseOnSurface', 'inverseSurface'], + ['onPrimaryFixed', 'primaryFixed'], + ['onSecondaryFixed', 'secondaryFixed'], + ['onTertiaryFixed', 'tertiaryFixed'], +]; + +/** WCAG 2.x minimum ratio per MD3 contrast level. */ +const WCAG_TARGET: Record, number> = { + medium: 4.5, + high: 7, +}; + +/** Theme colors are typed as `ColorValue`, but every built-in scheme uses an + * `rgba()` string. Anything else means the scheme is broken. */ +const asColor = (value: unknown) => { + if (typeof value !== 'string') { + throw new Error(`Expected a color string, received ${typeof value}`); + } + + return color(value); +}; + +const ratio = (foreground: unknown, background: unknown) => + asColor(foreground).contrast(asColor(background)); + +describe('contrast levels', () => { + describe.each(MODES)('%s', (mode) => { + it.each(NON_STANDARD)('defines every color role at %s', (contrast) => { + const standard = buildScheme(palette, { mode }); + const scheme = buildScheme(palette, { mode, contrast }); + + // Catches a role that is missing from the generated table. + expect(Object.keys(scheme).sort()).toEqual(Object.keys(standard).sort()); + + Object.entries(scheme).forEach(([role, value]) => { + expect(value).toBeDefined(); + expect(role.length && value).toBeTruthy(); + }); + + expect(Object.keys(scheme.elevation).sort()).toEqual( + Object.keys(standard.elevation).sort() + ); + }); + + it.each(NON_STANDARD)('meets WCAG contrast targets at %s', (contrast) => { + const { colors } = createTheme({ dark: mode === 'dark', contrast }); + const target = WCAG_TARGET[contrast]; + + const failures = CONTRAST_PAIRS.filter( + ([foreground, background]) => + ratio(colors[foreground], colors[background]) < target + ).map(([foreground, background]) => { + const value = ratio(colors[foreground], colors[background]); + return `${foreground} on ${background}: ${value.toFixed(2)} < ${target}`; + }); + + expect(failures).toEqual([]); + }); + + it.each(NON_STANDARD)( + 'raises contrast above standard at %s', + (contrast) => { + const isDark = mode === 'dark'; + const standard = createTheme({ dark: isDark }).colors; + const raised = createTheme({ dark: isDark, contrast }).colors; + + expect(ratio(raised.onPrimary, raised.primary)).toBeGreaterThan( + ratio(standard.onPrimary, standard.primary) + ); + } + ); + }); + + it('derives the pressed state layer from the scheme onSurface', () => { + const { colors } = createTheme({ contrast: 'high' }); + + expect(colors.stateLayerPressed).toBe( + asColor(colors.onSurface).alpha(0.1).rgb().string() + ); + expect(colors.stateLayerPressed).not.toBe( + LightTheme.colors.stateLayerPressed + ); + }); + + it('keeps elevation level0 transparent', () => { + NON_STANDARD.forEach((contrast) => { + expect(createTheme({ contrast }).colors.elevation.level0).toBe( + 'transparent' + ); + }); + }); + + it('defaults to standard, leaving the built-in themes unchanged', () => { + expect(createTheme({ dark: false }).colors).toStrictEqual( + LightTheme.colors + ); + expect(createTheme({ dark: true }).colors).toStrictEqual(DarkTheme.colors); + expect(LightTheme.contrast).toBe('standard'); + expect(DarkTheme.contrast).toBe('standard'); + }); +}); diff --git a/src/theme/provider.tsx b/src/theme/provider.tsx index 546a9dcf88..5ed9b9ee93 100644 --- a/src/theme/provider.tsx +++ b/src/theme/provider.tsx @@ -5,7 +5,8 @@ import { createTheming } from '@callstack/react-theme-provider'; import type { $DeepPartial } from '@callstack/react-theme-provider'; import { DarkTheme, LightTheme } from './schemes'; -import type { Theme, NavigationTheme } from './types'; +import { createTheme } from './schemes/createTheme'; +import type { ContrastLevel, Theme, NavigationTheme } from './types'; const { ThemeProvider, @@ -75,15 +76,26 @@ export const defaultThemes = { dark: DarkTheme, }; -export const getTheme = ( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - isDark: Scheme = false as Scheme -): (typeof defaultThemes)[Scheme extends true ? 'dark' : 'light'] => { - const scheme = isDark ? 'dark' : 'light'; - - return defaultThemes[scheme]; +/** Every light or dark and contrast pair, built once so that switching + * contrast at runtime does not rebuild a scheme. */ +const contrastThemes: Record<'light' | 'dark', Record> = { + light: { + standard: LightTheme, + medium: createTheme({ dark: false, contrast: 'medium' }), + high: createTheme({ dark: false, contrast: 'high' }), + }, + dark: { + standard: DarkTheme, + medium: createTheme({ dark: true, contrast: 'medium' }), + high: createTheme({ dark: true, contrast: 'high' }), + }, }; +export const getTheme = ( + isDark: boolean = false, + contrast: ContrastLevel = 'standard' +): Theme => contrastThemes[isDark ? 'dark' : 'light'][contrast]; + export function adaptNavigationTheme(themes: { reactNavigationLight: T; materialLight?: Theme; diff --git a/src/theme/schemes/DarkTheme.tsx b/src/theme/schemes/DarkTheme.tsx index 9b7ff60ef4..3c194559f8 100644 --- a/src/theme/schemes/DarkTheme.tsx +++ b/src/theme/schemes/DarkTheme.tsx @@ -1,12 +1,4 @@ -import { themeDefaults } from './base'; -import { tokens } from '../tokens'; -import { buildScheme } from '../tokens/sys/color'; -import { defaultShapes } from '../tokens/sys/shape'; +import { createTheme } from './createTheme'; import type { Theme } from '../types'; -export const DarkTheme: Theme = { - ...themeDefaults, - dark: true, - colors: buildScheme(tokens.md.ref.palette, { mode: 'dark' }), - shapes: defaultShapes, -}; +export const DarkTheme: Theme = createTheme({ dark: true }); diff --git a/src/theme/schemes/DynamicTheme.android.tsx b/src/theme/schemes/DynamicTheme.android.tsx index 0fb9c29b0e..2b86b5162c 100644 --- a/src/theme/schemes/DynamicTheme.android.tsx +++ b/src/theme/schemes/DynamicTheme.android.tsx @@ -1,9 +1,10 @@ import { Platform, PlatformColor, type ColorValue } from 'react-native'; +import { createTheme } from './createTheme'; import { DarkTheme } from './DarkTheme'; import { LightTheme } from './LightTheme'; import { Palette } from '../tokens'; -import type { Theme, ThemeColors } from '../types'; +import type { ContrastLevel, Theme, ThemeColors } from '../types'; const apiLevel = Platform.OS === 'android' ? Platform.Version : null; @@ -489,3 +490,22 @@ export const DynamicDarkTheme: Theme = { ...DarkTheme, colors: { ...DarkTheme.colors, ...darkDynamicColors }, }; + +/** Android has no high contrast version of its system colors, so dynamic + * color is only used at `standard` contrast. */ +export const isDynamicColorSupportedAtContrast = (contrast: ContrastLevel) => + isDynamicColorSupported && contrast === 'standard'; + +/** + * Dynamic theme for a scheme and contrast level. + */ +export const getDynamicTheme = ( + isDark: boolean, + contrast: ContrastLevel = 'standard' +): Theme => { + if (!isDynamicColorSupportedAtContrast(contrast)) { + return createTheme({ dark: isDark, contrast }); + } + + return isDark ? DynamicDarkTheme : DynamicLightTheme; +}; diff --git a/src/theme/schemes/DynamicTheme.tsx b/src/theme/schemes/DynamicTheme.tsx index a9049b86bf..ab0e4aa0c4 100644 --- a/src/theme/schemes/DynamicTheme.tsx +++ b/src/theme/schemes/DynamicTheme.tsx @@ -1,4 +1,15 @@ +import { createTheme } from './createTheme'; +import type { ContrastLevel, Theme } from '../types'; + export { DarkTheme as DynamicDarkTheme } from './DarkTheme'; export { LightTheme as DynamicLightTheme } from './LightTheme'; export const isDynamicColorSupported = false; + +export const isDynamicColorSupportedAtContrast = (_contrast: ContrastLevel) => + false; + +export const getDynamicTheme = ( + isDark: boolean, + contrast: ContrastLevel = 'standard' +): Theme => createTheme({ dark: isDark, contrast }); diff --git a/src/theme/schemes/LightTheme.tsx b/src/theme/schemes/LightTheme.tsx index 42593d5d42..7fde956331 100644 --- a/src/theme/schemes/LightTheme.tsx +++ b/src/theme/schemes/LightTheme.tsx @@ -1,12 +1,4 @@ -import { themeDefaults } from './base'; -import { tokens } from '../tokens'; -import { buildScheme } from '../tokens/sys/color'; -import { defaultShapes } from '../tokens/sys/shape'; +import { createTheme } from './createTheme'; import type { Theme } from '../types'; -export const LightTheme: Theme = { - ...themeDefaults, - dark: false, - colors: buildScheme(tokens.md.ref.palette, { mode: 'light' }), - shapes: defaultShapes, -}; +export const LightTheme: Theme = createTheme({ dark: false }); diff --git a/src/theme/schemes/base.ts b/src/theme/schemes/base.ts index 180ec1c155..c9a89aeb0d 100644 --- a/src/theme/schemes/base.ts +++ b/src/theme/schemes/base.ts @@ -4,7 +4,7 @@ import { defaultShapes } from '../tokens/sys/shape'; import { defaultFonts } from '../tokens/sys/typography'; import type { Theme } from '../types'; -type ThemeDefaults = Omit; +type ThemeDefaults = Omit; export const themeDefaults: ThemeDefaults = { animation: { diff --git a/src/theme/schemes/createTheme.ts b/src/theme/schemes/createTheme.ts new file mode 100644 index 0000000000..6aed8d2572 --- /dev/null +++ b/src/theme/schemes/createTheme.ts @@ -0,0 +1,28 @@ +import { themeDefaults } from './base'; +import { tokens } from '../tokens'; +import { buildScheme } from '../tokens/sys/color'; +import type { ContrastLevel, Theme } from '../types'; + +export type CreateThemeOptions = { + dark?: boolean; + contrast?: ContrastLevel; +}; + +/** + * Builds a theme for a given color scheme and contrast level. + * + * Prefer the `contrast` prop on `PaperProvider` over calling this directly, + * because passing a `theme` object turns off automatic system dark mode. + */ +export const createTheme = ({ + dark = false, + contrast = 'standard', +}: CreateThemeOptions = {}): Theme => ({ + ...themeDefaults, + dark, + contrast, + colors: buildScheme(tokens.md.ref.palette, { + mode: dark ? 'dark' : 'light', + contrast, + }), +}); diff --git a/src/theme/schemes/index.ts b/src/theme/schemes/index.ts index 37407657e2..ce456bedcc 100644 --- a/src/theme/schemes/index.ts +++ b/src/theme/schemes/index.ts @@ -1,7 +1,10 @@ export { LightTheme } from './LightTheme'; export { DarkTheme } from './DarkTheme'; +export { createTheme, type CreateThemeOptions } from './createTheme'; export { DynamicLightTheme, DynamicDarkTheme, + getDynamicTheme, isDynamicColorSupported, + isDynamicColorSupportedAtContrast, } from './DynamicTheme'; diff --git a/src/theme/tokens/sys/color.ts b/src/theme/tokens/sys/color.ts index a6c22ca0fb..ba54995013 100644 --- a/src/theme/tokens/sys/color.ts +++ b/src/theme/tokens/sys/color.ts @@ -1,7 +1,8 @@ import color from 'color'; +import { contrastSchemes } from './contrastSchemes'; import { state } from './state'; -import type { ElevationColors, ThemeColors } from '../../types'; +import type { ContrastLevel, ElevationColors, ThemeColors } from '../../types'; import { palette as defaultPalette } from '../ref/palette'; type Palette = typeof defaultPalette; @@ -10,11 +11,11 @@ type PaletteKey = keyof Palette; /** Roles that map 1:1 to a palette key. Excludes the computed fields. */ type MappedRoles = Omit; -type Contrast = 'standard'; // extend with 'medium' | 'high' when those ship - +/** Only `standard` uses the reference palette steps. The other levels need + * tones that are not in the palette, so they live in `./contrastSchemes`. */ const roleToTone: Record< 'light' | 'dark', - Record> + Record<'standard', Record> > = { light: { standard: { @@ -124,7 +125,10 @@ const roleToTone: Record< const elevationToTone: Record< 'light' | 'dark', - Record, PaletteKey>> + Record< + 'standard', + Record, PaletteKey> + > > = { light: { standard: { @@ -146,11 +150,34 @@ const elevationToTone: Record< }, }; +/** Works out the press state layer up front, because changing alpha at + * runtime breaks PlatformColor on Android. + * @see ThemeColors.stateLayerPressed */ +const withPressedOpacity = (onSurface: string) => + color(onSurface).alpha(state.opacity.pressed).rgb().string(); + +/** + * Builds the color scheme for a mode and contrast level. + * + * `palette` is only used at `standard` contrast. The `medium` and `high` + * schemes already hold color values, so a custom `palette` is ignored there. + */ export function buildScheme( palette: Palette, - opts: { mode: 'light' | 'dark'; contrast?: Contrast } + opts: { mode: 'light' | 'dark'; contrast?: ContrastLevel } ): ThemeColors { const contrast = opts.contrast ?? 'standard'; + + if (contrast !== 'standard') { + const { roles, elevation } = contrastSchemes[opts.mode][contrast]; + + return { + ...roles, + stateLayerPressed: withPressedOpacity(roles.onSurface), + elevation: { level0: 'transparent', ...elevation }, + }; + } + const tones = roleToTone[opts.mode][contrast]; const elevTones = elevationToTone[opts.mode][contrast]; @@ -161,10 +188,7 @@ export function buildScheme( return { ...mapped, - stateLayerPressed: color(palette[tones.onSurface]) - .alpha(state.opacity.pressed) - .rgb() - .string(), + stateLayerPressed: withPressedOpacity(palette[tones.onSurface]), elevation: { level0: 'transparent', level1: palette[elevTones.level1], diff --git a/src/theme/tokens/sys/contrastSchemes.ts b/src/theme/tokens/sys/contrastSchemes.ts new file mode 100644 index 0000000000..30c48f1615 --- /dev/null +++ b/src/theme/tokens/sys/contrastSchemes.ts @@ -0,0 +1,264 @@ +/** + * GENERATED by scripts/generate-contrast-tokens.ts, do not edit by hand. + * Run `yarn generate-contrast-tokens` to regenerate. + * + * MD3 medium and high contrast schemes, seeded from #6750A4. + * + */ +import type { ContrastLevel, ElevationColors, ThemeColors } from '../../types'; + +type GeneratedContrast = Exclude; + +type GeneratedScheme = { + roles: Record< + Exclude, + string + >; + elevation: Record, string>; +}; + +export const contrastSchemes: Record< + 'light' | 'dark', + Record +> = { + light: { + medium: { + roles: { + primary: 'rgba(60, 45, 99, 1)', + primaryContainer: 'rgba(116, 100, 159, 1)', + secondary: 'rgba(57, 51, 71, 1)', + secondaryContainer: 'rgba(113, 106, 128, 1)', + tertiary: 'rgba(80, 43, 56, 1)', + tertiaryContainer: 'rgba(142, 96, 111, 1)', + surface: 'rgba(253, 247, 255, 1)', + surfaceDim: 'rgba(202, 197, 204, 1)', + surfaceBright: 'rgba(253, 247, 255, 1)', + surfaceContainerLowest: 'rgba(255, 255, 255, 1)', + surfaceContainerLow: 'rgba(248, 242, 250, 1)', + surfaceContainer: 'rgba(236, 230, 238, 1)', + surfaceContainerHigh: 'rgba(225, 219, 227, 1)', + surfaceContainerHighest: 'rgba(213, 208, 216, 1)', + surfaceVariant: 'rgba(231, 224, 235, 1)', + background: 'rgba(253, 247, 255, 1)', + error: 'rgba(116, 0, 6, 1)', + errorContainer: 'rgba(207, 44, 39, 1)', + onPrimary: 'rgba(255, 255, 255, 1)', + onPrimaryContainer: 'rgba(255, 255, 255, 1)', + onSecondary: 'rgba(255, 255, 255, 1)', + onSecondaryContainer: 'rgba(255, 255, 255, 1)', + onTertiary: 'rgba(255, 255, 255, 1)', + onTertiaryContainer: 'rgba(255, 255, 255, 1)', + onSurface: 'rgba(18, 16, 22, 1)', + onSurfaceVariant: 'rgba(56, 53, 61, 1)', + onError: 'rgba(255, 255, 255, 1)', + onErrorContainer: 'rgba(255, 255, 255, 1)', + onBackground: 'rgba(29, 27, 32, 1)', + outline: 'rgba(84, 81, 90, 1)', + outlineVariant: 'rgba(111, 107, 117, 1)', + inverseSurface: 'rgba(50, 47, 53, 1)', + inverseOnSurface: 'rgba(245, 239, 247, 1)', + inversePrimary: 'rgba(207, 189, 254, 1)', + primaryFixed: 'rgba(116, 100, 159, 1)', + primaryFixedDim: 'rgba(91, 76, 132, 1)', + onPrimaryFixed: 'rgba(255, 255, 255, 1)', + onPrimaryFixedVariant: 'rgba(255, 255, 255, 1)', + secondaryFixed: 'rgba(113, 106, 128, 1)', + secondaryFixedDim: 'rgba(88, 82, 103, 1)', + onSecondaryFixed: 'rgba(255, 255, 255, 1)', + onSecondaryFixedVariant: 'rgba(255, 255, 255, 1)', + tertiaryFixed: 'rgba(142, 96, 111, 1)', + tertiaryFixedDim: 'rgba(115, 72, 86, 1)', + onTertiaryFixed: 'rgba(255, 255, 255, 1)', + onTertiaryFixedVariant: 'rgba(255, 255, 255, 1)', + shadow: 'rgba(0, 0, 0, 1)', + scrim: 'rgba(0, 0, 0, 1)', + }, + elevation: { + level1: 'rgba(248, 242, 250, 1)', + level2: 'rgba(236, 230, 238, 1)', + level3: 'rgba(225, 219, 227, 1)', + level4: 'rgba(225, 219, 227, 1)', + level5: 'rgba(213, 208, 216, 1)', + }, + }, + high: { + roles: { + primary: 'rgba(49, 34, 89, 1)', + primaryContainer: 'rgba(79, 64, 120, 1)', + secondary: 'rgba(47, 41, 60, 1)', + secondaryContainer: 'rgba(76, 70, 91, 1)', + tertiary: 'rgba(69, 33, 46, 1)', + tertiaryContainer: 'rgba(102, 61, 75, 1)', + surface: 'rgba(253, 247, 255, 1)', + surfaceDim: 'rgba(188, 183, 191, 1)', + surfaceBright: 'rgba(253, 247, 255, 1)', + surfaceContainerLowest: 'rgba(255, 255, 255, 1)', + surfaceContainerLow: 'rgba(245, 239, 247, 1)', + surfaceContainer: 'rgba(230, 224, 233, 1)', + surfaceContainerHigh: 'rgba(216, 210, 218, 1)', + surfaceContainerHighest: 'rgba(202, 197, 204, 1)', + surfaceVariant: 'rgba(231, 224, 235, 1)', + background: 'rgba(253, 247, 255, 1)', + error: 'rgba(96, 0, 4, 1)', + errorContainer: 'rgba(152, 0, 10, 1)', + onPrimary: 'rgba(255, 255, 255, 1)', + onPrimaryContainer: 'rgba(255, 255, 255, 1)', + onSecondary: 'rgba(255, 255, 255, 1)', + onSecondaryContainer: 'rgba(255, 255, 255, 1)', + onTertiary: 'rgba(255, 255, 255, 1)', + onTertiaryContainer: 'rgba(255, 255, 255, 1)', + onSurface: 'rgba(0, 0, 0, 1)', + onSurfaceVariant: 'rgba(0, 0, 0, 1)', + onError: 'rgba(255, 255, 255, 1)', + onErrorContainer: 'rgba(255, 255, 255, 1)', + onBackground: 'rgba(29, 27, 32, 1)', + outline: 'rgba(46, 43, 51, 1)', + outlineVariant: 'rgba(75, 72, 81, 1)', + inverseSurface: 'rgba(50, 47, 53, 1)', + inverseOnSurface: 'rgba(255, 255, 255, 1)', + inversePrimary: 'rgba(207, 189, 254, 1)', + primaryFixed: 'rgba(79, 64, 120, 1)', + primaryFixedDim: 'rgba(56, 41, 96, 1)', + onPrimaryFixed: 'rgba(255, 255, 255, 1)', + onPrimaryFixedVariant: 'rgba(255, 255, 255, 1)', + secondaryFixed: 'rgba(76, 70, 91, 1)', + secondaryFixedDim: 'rgba(53, 48, 67, 1)', + onSecondaryFixed: 'rgba(255, 255, 255, 1)', + onSecondaryFixedVariant: 'rgba(255, 255, 255, 1)', + tertiaryFixed: 'rgba(102, 61, 75, 1)', + tertiaryFixedDim: 'rgba(76, 39, 52, 1)', + onTertiaryFixed: 'rgba(255, 255, 255, 1)', + onTertiaryFixedVariant: 'rgba(255, 255, 255, 1)', + shadow: 'rgba(0, 0, 0, 1)', + scrim: 'rgba(0, 0, 0, 1)', + }, + elevation: { + level1: 'rgba(245, 239, 247, 1)', + level2: 'rgba(230, 224, 233, 1)', + level3: 'rgba(216, 210, 218, 1)', + level4: 'rgba(216, 210, 218, 1)', + level5: 'rgba(202, 197, 204, 1)', + }, + }, + }, + dark: { + medium: { + roles: { + primary: 'rgba(227, 214, 255, 1)', + primaryContainer: 'rgba(152, 135, 197, 1)', + secondary: 'rgba(226, 216, 242, 1)', + secondaryContainer: 'rgba(149, 141, 164, 1)', + tertiary: 'rgba(255, 208, 221, 1)', + tertiaryContainer: 'rgba(181, 131, 146, 1)', + surface: 'rgba(20, 18, 24, 1)', + surfaceDim: 'rgba(20, 18, 24, 1)', + surfaceBright: 'rgba(70, 67, 74, 1)', + surfaceContainerLowest: 'rgba(8, 7, 11, 1)', + surfaceContainerLow: 'rgba(31, 29, 34, 1)', + surfaceContainer: 'rgba(41, 39, 45, 1)', + surfaceContainerHigh: 'rgba(52, 49, 56, 1)', + surfaceContainerHighest: 'rgba(63, 60, 67, 1)', + surfaceVariant: 'rgba(73, 69, 78, 1)', + background: 'rgba(20, 18, 24, 1)', + error: 'rgba(255, 210, 204, 1)', + errorContainer: 'rgba(255, 84, 73, 1)', + onPrimary: 'rgba(43, 27, 82, 1)', + onPrimaryContainer: 'rgba(0, 0, 0, 1)', + onSecondary: 'rgba(40, 35, 54, 1)', + onSecondaryContainer: 'rgba(0, 0, 0, 1)', + onTertiary: 'rgba(61, 26, 39, 1)', + onTertiaryContainer: 'rgba(0, 0, 0, 1)', + onSurface: 'rgba(255, 255, 255, 1)', + onSurfaceVariant: 'rgba(224, 218, 229, 1)', + onError: 'rgba(84, 0, 3, 1)', + onErrorContainer: 'rgba(0, 0, 0, 1)', + onBackground: 'rgba(230, 224, 233, 1)', + outline: 'rgba(181, 176, 187, 1)', + outlineVariant: 'rgba(147, 142, 153, 1)', + inverseSurface: 'rgba(230, 224, 233, 1)', + inverseOnSurface: 'rgba(43, 41, 47, 1)', + inversePrimary: 'rgba(78, 63, 119, 1)', + primaryFixed: 'rgba(233, 221, 255, 1)', + primaryFixedDim: 'rgba(207, 189, 254, 1)', + onPrimaryFixed: 'rgba(22, 3, 61, 1)', + onPrimaryFixedVariant: 'rgba(60, 45, 99, 1)', + secondaryFixed: 'rgba(232, 222, 248, 1)', + secondaryFixedDim: 'rgba(203, 194, 219, 1)', + onSecondaryFixed: 'rgba(19, 14, 32, 1)', + onSecondaryFixedVariant: 'rgba(57, 51, 71, 1)', + tertiaryFixed: 'rgba(255, 217, 227, 1)', + tertiaryFixedDim: 'rgba(239, 184, 200, 1)', + onTertiaryFixed: 'rgba(36, 6, 19, 1)', + onTertiaryFixedVariant: 'rgba(80, 43, 56, 1)', + shadow: 'rgba(0, 0, 0, 1)', + scrim: 'rgba(0, 0, 0, 1)', + }, + elevation: { + level1: 'rgba(31, 29, 34, 1)', + level2: 'rgba(41, 39, 45, 1)', + level3: 'rgba(52, 49, 56, 1)', + level4: 'rgba(52, 49, 56, 1)', + level5: 'rgba(63, 60, 67, 1)', + }, + }, + high: { + roles: { + primary: 'rgba(245, 237, 255, 1)', + primaryContainer: 'rgba(203, 185, 250, 1)', + secondary: 'rgba(245, 237, 255, 1)', + secondaryContainer: 'rgba(200, 191, 216, 1)', + tertiary: 'rgba(255, 235, 239, 1)', + tertiaryContainer: 'rgba(235, 180, 196, 1)', + surface: 'rgba(20, 18, 24, 1)', + surfaceDim: 'rgba(20, 18, 24, 1)', + surfaceBright: 'rgba(82, 79, 85, 1)', + surfaceContainerLowest: 'rgba(0, 0, 0, 1)', + surfaceContainerLow: 'rgba(33, 31, 36, 1)', + surfaceContainer: 'rgba(50, 47, 53, 1)', + surfaceContainerHigh: 'rgba(61, 58, 65, 1)', + surfaceContainerHighest: 'rgba(72, 70, 76, 1)', + surfaceVariant: 'rgba(73, 69, 78, 1)', + background: 'rgba(20, 18, 24, 1)', + error: 'rgba(255, 236, 233, 1)', + errorContainer: 'rgba(255, 174, 164, 1)', + onPrimary: 'rgba(0, 0, 0, 1)', + onPrimaryContainer: 'rgba(15, 0, 51, 1)', + onSecondary: 'rgba(0, 0, 0, 1)', + onSecondaryContainer: 'rgba(13, 8, 26, 1)', + onTertiary: 'rgba(0, 0, 0, 1)', + onTertiaryContainer: 'rgba(29, 2, 13, 1)', + onSurface: 'rgba(255, 255, 255, 1)', + onSurfaceVariant: 'rgba(255, 255, 255, 1)', + onError: 'rgba(0, 0, 0, 1)', + onErrorContainer: 'rgba(34, 0, 1, 1)', + onBackground: 'rgba(230, 224, 233, 1)', + outline: 'rgba(244, 238, 249, 1)', + outlineVariant: 'rgba(198, 192, 203, 1)', + inverseSurface: 'rgba(230, 224, 233, 1)', + inverseOnSurface: 'rgba(0, 0, 0, 1)', + inversePrimary: 'rgba(78, 63, 119, 1)', + primaryFixed: 'rgba(233, 221, 255, 1)', + primaryFixedDim: 'rgba(207, 189, 254, 1)', + onPrimaryFixed: 'rgba(0, 0, 0, 1)', + onPrimaryFixedVariant: 'rgba(22, 3, 61, 1)', + secondaryFixed: 'rgba(232, 222, 248, 1)', + secondaryFixedDim: 'rgba(203, 194, 219, 1)', + onSecondaryFixed: 'rgba(0, 0, 0, 1)', + onSecondaryFixedVariant: 'rgba(19, 14, 32, 1)', + tertiaryFixed: 'rgba(255, 217, 227, 1)', + tertiaryFixedDim: 'rgba(239, 184, 200, 1)', + onTertiaryFixed: 'rgba(0, 0, 0, 1)', + onTertiaryFixedVariant: 'rgba(36, 6, 19, 1)', + shadow: 'rgba(0, 0, 0, 1)', + scrim: 'rgba(0, 0, 0, 1)', + }, + elevation: { + level1: 'rgba(33, 31, 36, 1)', + level2: 'rgba(50, 47, 53, 1)', + level3: 'rgba(61, 58, 65, 1)', + level4: 'rgba(61, 58, 65, 1)', + level5: 'rgba(72, 70, 76, 1)', + }, + }, + }, +} as const; diff --git a/src/theme/types/theme.ts b/src/theme/types/theme.ts index a4ce2288ae..9dd54d6cf7 100644 --- a/src/theme/types/theme.ts +++ b/src/theme/types/theme.ts @@ -6,8 +6,11 @@ import type { MotionConfig } from './motion'; import type { ThemeShapes } from './shape'; import type { Typescale } from './typography'; +export type ContrastLevel = 'standard' | 'medium' | 'high'; + export type Theme = { dark: boolean; + contrast: ContrastLevel; animation: { scale: number; }; diff --git a/yarn.lock b/yarn.lock index 100597d848..9d471ed1fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3988,6 +3988,13 @@ __metadata: languageName: node linkType: hard +"@material/material-color-utilities@npm:0.3.0": + version: 0.3.0 + resolution: "@material/material-color-utilities@npm:0.3.0" + checksum: 10c0/3bef025428b893f2acc9e9e2bd186363a60b7c0836fe43c78222e29fe67dc579618e844f0661a20657ed9f7fd8b94fd43a2961892894d0b6a2ba5264ce2673f8 + languageName: node + linkType: hard + "@mdx-js/mdx@npm:^0.20.3": version: 0.20.3 resolution: "@mdx-js/mdx@npm:0.20.3" @@ -18218,6 +18225,7 @@ __metadata: "@commitlint/config-conventional": "npm:^8.3.4" "@eslint/js": "npm:9.39.4" "@jest/globals": "npm:^29.7.0" + "@material/material-color-utilities": "npm:0.3.0" "@react-native-vector-icons/material-design-icons": "npm:^12.0.0" "@react-native/babel-preset": "npm:^0.85.3" "@react-native/jest-preset": "npm:^0.85.3" From 03c403bc08fe8e5e10bd2cb51119bbc600b02ca2 Mon Sep 17 00:00:00 2001 From: Hristo Totov Date: Sun, 6 Sep 2026 19:28:38 +0300 Subject: [PATCH 2/3] fix(): adress cr comments --- example/src/index.tsx | 4 +- package.json | 2 - scripts/generate-contrast-tokens.ts | 207 ------------------- src/core/PaperProvider.tsx | 2 + src/theme/__tests__/contrast.test.ts | 39 ++++ src/theme/tokens/sys/color.ts | 259 +++++++++++++++++++---- src/theme/tokens/sys/contrastSchemes.ts | 264 ------------------------ yarn.lock | 8 - 8 files changed, 262 insertions(+), 523 deletions(-) delete mode 100644 scripts/generate-contrast-tokens.ts delete mode 100644 src/theme/tokens/sys/contrastSchemes.ts diff --git a/example/src/index.tsx b/example/src/index.tsx index 47ad19db06..9491583522 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -14,8 +14,8 @@ import { StatusBar } from 'expo-status-bar'; import * as Updates from 'expo-updates'; import { PaperProvider, - createTheme, getDynamicTheme, + getTheme, type ContrastLevel, } from 'react-native-paper'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -101,7 +101,7 @@ export default function PaperExample() { const theme = dynamicThemeSupported && shouldUseDynamicTheme ? getDynamicTheme(isDarkMode, contrast) - : createTheme({ dark: isDarkMode, contrast }); + : getTheme(isDarkMode, contrast); const direction = rtl ? 'rtl' : 'ltr'; diff --git a/package.json b/package.json index af16cdb4a7..6db11f9fef 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "test": "jest", "prepack": "bob build", "generate-mappings": "node ./scripts/generate-mappings.ts", - "generate-contrast-tokens": "node ./scripts/generate-contrast-tokens.ts", "release": "release-it --only-version", "docs": "yarn --cwd docs", "example": "yarn --cwd example" @@ -62,7 +61,6 @@ "@commitlint/config-conventional": "^8.3.4", "@eslint/js": "9.39.4", "@jest/globals": "^29.7.0", - "@material/material-color-utilities": "0.3.0", "@react-native-vector-icons/material-design-icons": "^12.0.0", "@react-native/babel-preset": "^0.85.3", "@react-native/jest-preset": "^0.85.3", diff --git a/scripts/generate-contrast-tokens.ts b/scripts/generate-contrast-tokens.ts deleted file mode 100644 index dd5414a4c7..0000000000 --- a/scripts/generate-contrast-tokens.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Generates the MD3 medium and high contrast schemes into - * `src/theme/tokens/sys/contrastSchemes.ts`. - * - * MD3 does not pick a different palette step for medium and high contrast. - * For each role it looks for the tone that hits a target contrast ratio - * against that role's background. The tones it finds are usually not whole numbers. - * For example `primary` at light/high lands on tone 13.3, so it cannot be - * written as a key of `ref/palette.ts`. - * - * Run with `yarn generate-contrast-tokens`. - */ -import { - Hct, - MaterialDynamicColors, - SchemeTonalSpot, - argbFromHex, - blueFromArgb, - greenFromArgb, - redFromArgb, -} from '@material/material-color-utilities'; -import { writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import type { ElevationColors, ThemeColors } from '../src/theme/types'; - -type Role = Exclude; - -type ElevationKey = Exclude; - -const SEED = '#6750A4'; - -const MODES = [ - ['light', false], - ['dark', true], -] as const; - -const CONTRASTS = [ - ['medium', 0.5], - ['high', 1.0], -] as const; - -// The roles to generate, in the order they are written to the output file. -const ROLES = [ - 'primary', - 'primaryContainer', - 'secondary', - 'secondaryContainer', - 'tertiary', - 'tertiaryContainer', - 'surface', - 'surfaceDim', - 'surfaceBright', - 'surfaceContainerLowest', - 'surfaceContainerLow', - 'surfaceContainer', - 'surfaceContainerHigh', - 'surfaceContainerHighest', - 'surfaceVariant', - 'background', - 'error', - 'errorContainer', - 'onPrimary', - 'onPrimaryContainer', - 'onSecondary', - 'onSecondaryContainer', - 'onTertiary', - 'onTertiaryContainer', - 'onSurface', - 'onSurfaceVariant', - 'onError', - 'onErrorContainer', - 'onBackground', - 'outline', - 'outlineVariant', - 'inverseSurface', - 'inverseOnSurface', - 'inversePrimary', - 'primaryFixed', - 'primaryFixedDim', - 'onPrimaryFixed', - 'onPrimaryFixedVariant', - 'secondaryFixed', - 'secondaryFixedDim', - 'onSecondaryFixed', - 'onSecondaryFixedVariant', - 'tertiaryFixed', - 'tertiaryFixedDim', - 'onTertiaryFixed', - 'onTertiaryFixedVariant', - 'shadow', - 'scrim', -] as const satisfies readonly Role[]; - -// Fails to compile if a role in `ThemeColors` is missing from `ROLES`. -type MissingRoles = Exclude; - -type AssertNoMissingRoles = T; - -export type _RolesAreComplete = AssertNoMissingRoles; - -/** - * Tonal elevation surfaces. MD3 maps these onto the surface container roles. - * `level0` is always transparent, so it is not generated. - */ -const ELEVATION_ROLES: Record = { - level1: 'surfaceContainerLow', - level2: 'surfaceContainer', - level3: 'surfaceContainerHigh', - level4: 'surfaceContainerHigh', - level5: 'surfaceContainerHighest', -}; - -/** Matches the `rgba(r, g, b, 1)` format used throughout the theme tokens. */ -const toRgbaString = (argb: number) => - `rgba(${redFromArgb(argb)}, ${greenFromArgb(argb)}, ${blueFromArgb(argb)}, 1)`; - -const lookupRole = (role: string): unknown => - Object.entries(MaterialDynamicColors).find(([key]) => key === role)?.[1]; - -const resolveRole = (scheme: SchemeTonalSpot, role: string) => { - const dynamicColor = lookupRole(role); - - if ( - dynamicColor == null || - typeof dynamicColor !== 'object' || - !('getArgb' in dynamicColor) || - typeof dynamicColor.getArgb !== 'function' - ) { - throw new Error( - `@material/material-color-utilities does not expose the "${role}" role. ` + - `Check the pinned version, role coverage differs between releases.` - ); - } - - return toRgbaString(dynamicColor.getArgb(scheme)); -}; - -const seed = Hct.fromInt(argbFromHex(SEED)); - -const schemes = MODES.map(([mode, isDark]) => { - const contrasts = CONTRASTS.map(([contrast, level]) => { - const scheme = new SchemeTonalSpot(seed, isDark, level); - - const roles = ROLES.map( - (role) => ` ${role}: '${resolveRole(scheme, role)}',` - ).join('\n'); - - const elevation = Object.entries(ELEVATION_ROLES) - .map(([key, role]) => ` ${key}: '${resolveRole(scheme, role)}',`) - .join('\n'); - - return [ - ` ${contrast}: {`, - ` roles: {`, - roles, - ` },`, - ` elevation: {`, - elevation, - ` },`, - ` },`, - ].join('\n'); - }).join('\n'); - - return [` ${mode}: {`, contrasts, ` },`].join('\n'); -}).join('\n'); - -const output = `/** - * GENERATED by scripts/generate-contrast-tokens.ts, do not edit by hand. - * Run \`yarn generate-contrast-tokens\` to regenerate. - * - * MD3 medium and high contrast schemes, seeded from ${SEED}. - * - */ -import type { ContrastLevel, ElevationColors, ThemeColors } from '../../types'; - -type GeneratedContrast = Exclude; - -type GeneratedScheme = { - roles: Record< - Exclude, - string - >; - elevation: Record, string>; -}; - -export const contrastSchemes: Record< - 'light' | 'dark', - Record -> = { -${schemes} -} as const; -`; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const outputPath = resolve( - scriptDir, - '..', - 'src/theme/tokens/sys/contrastSchemes.ts' -); - -writeFileSync(outputPath, output); - -console.log( - `Generated ${ROLES.length} roles x ${MODES.length} modes x ${CONTRASTS.length} contrast levels -> ${outputPath}` -); diff --git a/src/core/PaperProvider.tsx b/src/core/PaperProvider.tsx index e16d7fa77d..0cbc86b170 100644 --- a/src/core/PaperProvider.tsx +++ b/src/core/PaperProvider.tsx @@ -38,7 +38,9 @@ const PaperProvider = (props: Props) => { const theme = React.useMemo(() => { const isDark = props.theme?.dark ?? colorScheme === 'dark'; + // The prop wins over a level set on a custom theme object const level = contrast ?? props.theme?.contrast ?? 'standard'; + // `level` is the scheme we picked, `theme.colors` still override it const base = getTheme(isDark, level); const scale = resolvedReduceMotion ? 0 diff --git a/src/theme/__tests__/contrast.test.ts b/src/theme/__tests__/contrast.test.ts index 3b3fbb92ac..61df9830c6 100644 --- a/src/theme/__tests__/contrast.test.ts +++ b/src/theme/__tests__/contrast.test.ts @@ -113,6 +113,45 @@ describe('contrast levels', () => { ); }); + it('keeps the fixed roles the same at every contrast level', () => { + // MD3 defines the *Fixed roles as stable across contrast levels. + let checked = 0; + + MODES.forEach((mode) => { + const isDark = mode === 'dark'; + const standard = createTheme({ dark: isDark }).colors; + + NON_STANDARD.forEach((contrast) => { + const raised = createTheme({ dark: isDark, contrast }).colors; + + const fixedOf = (colors: ThemeColors) => + Object.entries(colors).filter(([role]) => role.includes('Fixed')); + + const before = fixedOf(standard); + checked += before.length; + + expect(fixedOf(raised)).toStrictEqual(before); + }); + }); + + expect(checked).toBeGreaterThan(0); + }); + + it('keeps a container distinct from its base role', () => { + // A container collapsing onto its base role means the scheme has clipped. + NON_STANDARD.forEach((contrast) => { + MODES.forEach((mode) => { + const { colors } = createTheme({ dark: mode === 'dark', contrast }); + + expect(colors.primaryContainer).not.toBe(colors.primary); + expect(colors.secondaryContainer).not.toBe(colors.secondary); + expect(colors.tertiaryContainer).not.toBe(colors.tertiary); + expect(colors.errorContainer).not.toBe(colors.error); + expect(colors.outlineVariant).not.toBe(colors.outline); + }); + }); + }); + it('keeps elevation level0 transparent', () => { NON_STANDARD.forEach((contrast) => { expect(createTheme({ contrast }).colors.elevation.level0).toBe( diff --git a/src/theme/tokens/sys/color.ts b/src/theme/tokens/sys/color.ts index ba54995013..ae05842227 100644 --- a/src/theme/tokens/sys/color.ts +++ b/src/theme/tokens/sys/color.ts @@ -1,6 +1,5 @@ import color from 'color'; -import { contrastSchemes } from './contrastSchemes'; import { state } from './state'; import type { ContrastLevel, ElevationColors, ThemeColors } from '../../types'; import { palette as defaultPalette } from '../ref/palette'; @@ -11,11 +10,14 @@ type PaletteKey = keyof Palette; /** Roles that map 1:1 to a palette key. Excludes the computed fields. */ type MappedRoles = Omit; -/** Only `standard` uses the reference palette steps. The other levels need - * tones that are not in the palette, so they live in `./contrastSchemes`. */ +/** Role to palette step for each MD3 contrast level. + * + * Raising contrast moves the accent and outline roles toward the extremes of + * their tonal palette. Surfaces and the `*Fixed` roles do not change, since + * MD3 keeps those stable across levels. */ const roleToTone: Record< 'light' | 'dark', - Record<'standard', Record> + Record> > = { light: { standard: { @@ -68,6 +70,106 @@ const roleToTone: Record< shadow: 'neutral0', scrim: 'neutral0', }, + medium: { + primary: 'primary30', + onPrimary: 'primary100', + primaryContainer: 'primary40', + onPrimaryContainer: 'primary100', + secondary: 'secondary30', + onSecondary: 'secondary100', + secondaryContainer: 'secondary40', + onSecondaryContainer: 'secondary100', + tertiary: 'tertiary30', + onTertiary: 'tertiary100', + tertiaryContainer: 'tertiary40', + onTertiaryContainer: 'tertiary100', + error: 'error30', + onError: 'error100', + errorContainer: 'error40', + onErrorContainer: 'error100', + surface: 'neutral98', + surfaceDim: 'neutral87', + surfaceBright: 'neutral98', + surfaceContainerLowest: 'neutral100', + surfaceContainerLow: 'neutral96', + surfaceContainer: 'neutral94', + surfaceContainerHigh: 'neutral92', + surfaceContainerHighest: 'neutral90', + surfaceVariant: 'neutralVariant90', + background: 'neutral98', + onSurface: 'neutral10', + onSurfaceVariant: 'neutralVariant30', + onBackground: 'neutral10', + outline: 'neutralVariant40', + outlineVariant: 'neutralVariant60', + inverseSurface: 'neutral20', + inverseOnSurface: 'neutral95', + inversePrimary: 'primary90', + primaryFixed: 'primary90', + primaryFixedDim: 'primary80', + onPrimaryFixed: 'primary10', + onPrimaryFixedVariant: 'primary30', + secondaryFixed: 'secondary90', + secondaryFixedDim: 'secondary80', + onSecondaryFixed: 'secondary10', + onSecondaryFixedVariant: 'secondary30', + tertiaryFixed: 'tertiary90', + tertiaryFixedDim: 'tertiary80', + onTertiaryFixed: 'tertiary10', + onTertiaryFixedVariant: 'tertiary30', + shadow: 'neutral0', + scrim: 'neutral0', + }, + high: { + primary: 'primary20', + onPrimary: 'primary100', + primaryContainer: 'primary30', + onPrimaryContainer: 'primary100', + secondary: 'secondary20', + onSecondary: 'secondary100', + secondaryContainer: 'secondary30', + onSecondaryContainer: 'secondary100', + tertiary: 'tertiary20', + onTertiary: 'tertiary100', + tertiaryContainer: 'tertiary30', + onTertiaryContainer: 'tertiary100', + error: 'error20', + onError: 'error100', + errorContainer: 'error30', + onErrorContainer: 'error100', + surface: 'neutral98', + surfaceDim: 'neutral87', + surfaceBright: 'neutral98', + surfaceContainerLowest: 'neutral100', + surfaceContainerLow: 'neutral96', + surfaceContainer: 'neutral94', + surfaceContainerHigh: 'neutral92', + surfaceContainerHighest: 'neutral90', + surfaceVariant: 'neutralVariant90', + background: 'neutral98', + onSurface: 'neutral0', + onSurfaceVariant: 'neutralVariant20', + onBackground: 'neutral0', + outline: 'neutralVariant20', + outlineVariant: 'neutralVariant40', + inverseSurface: 'neutral20', + inverseOnSurface: 'neutral95', + inversePrimary: 'primary95', + primaryFixed: 'primary90', + primaryFixedDim: 'primary80', + onPrimaryFixed: 'primary10', + onPrimaryFixedVariant: 'primary30', + secondaryFixed: 'secondary90', + secondaryFixedDim: 'secondary80', + onSecondaryFixed: 'secondary10', + onSecondaryFixedVariant: 'secondary30', + tertiaryFixed: 'tertiary90', + tertiaryFixedDim: 'tertiary80', + onTertiaryFixed: 'tertiary10', + onTertiaryFixedVariant: 'tertiary30', + shadow: 'neutral0', + scrim: 'neutral0', + }, }, dark: { standard: { @@ -120,33 +222,126 @@ const roleToTone: Record< shadow: 'neutral0', scrim: 'neutral0', }, + medium: { + primary: 'primary90', + onPrimary: 'primary10', + primaryContainer: 'primary70', + onPrimaryContainer: 'primary0', + secondary: 'secondary90', + onSecondary: 'secondary10', + secondaryContainer: 'secondary70', + onSecondaryContainer: 'secondary0', + tertiary: 'tertiary90', + onTertiary: 'tertiary10', + tertiaryContainer: 'tertiary70', + onTertiaryContainer: 'tertiary0', + error: 'error90', + onError: 'error10', + errorContainer: 'error70', + onErrorContainer: 'error0', + surface: 'neutral6', + surfaceDim: 'neutral6', + surfaceBright: 'neutral24', + surfaceContainerLowest: 'neutral4', + surfaceContainerLow: 'neutral10', + surfaceContainer: 'neutral12', + surfaceContainerHigh: 'neutral17', + surfaceContainerHighest: 'neutral22', + surfaceVariant: 'neutralVariant30', + background: 'neutral6', + onSurface: 'neutral100', + onSurfaceVariant: 'neutralVariant90', + onBackground: 'neutral100', + outline: 'neutralVariant70', + outlineVariant: 'neutralVariant50', + inverseSurface: 'neutral90', + inverseOnSurface: 'neutral20', + inversePrimary: 'primary30', + primaryFixed: 'primary90', + primaryFixedDim: 'primary80', + onPrimaryFixed: 'primary10', + onPrimaryFixedVariant: 'primary30', + secondaryFixed: 'secondary90', + secondaryFixedDim: 'secondary80', + onSecondaryFixed: 'secondary10', + onSecondaryFixedVariant: 'secondary30', + tertiaryFixed: 'tertiary90', + tertiaryFixedDim: 'tertiary80', + onTertiaryFixed: 'tertiary10', + onTertiaryFixedVariant: 'tertiary30', + shadow: 'neutral0', + scrim: 'neutral0', + }, + high: { + primary: 'primary95', + onPrimary: 'primary0', + primaryContainer: 'primary80', + onPrimaryContainer: 'primary0', + secondary: 'secondary95', + onSecondary: 'secondary0', + secondaryContainer: 'secondary80', + onSecondaryContainer: 'secondary0', + tertiary: 'tertiary95', + onTertiary: 'tertiary0', + tertiaryContainer: 'tertiary80', + onTertiaryContainer: 'tertiary0', + error: 'error95', + onError: 'error0', + errorContainer: 'error80', + onErrorContainer: 'error0', + surface: 'neutral6', + surfaceDim: 'neutral6', + surfaceBright: 'neutral24', + surfaceContainerLowest: 'neutral4', + surfaceContainerLow: 'neutral10', + surfaceContainer: 'neutral12', + surfaceContainerHigh: 'neutral17', + surfaceContainerHighest: 'neutral22', + surfaceVariant: 'neutralVariant30', + background: 'neutral6', + onSurface: 'neutral100', + onSurfaceVariant: 'neutralVariant95', + onBackground: 'neutral100', + outline: 'neutralVariant80', + outlineVariant: 'neutralVariant60', + inverseSurface: 'neutral90', + inverseOnSurface: 'neutral20', + inversePrimary: 'primary20', + primaryFixed: 'primary90', + primaryFixedDim: 'primary80', + onPrimaryFixed: 'primary10', + onPrimaryFixedVariant: 'primary30', + secondaryFixed: 'secondary90', + secondaryFixedDim: 'secondary80', + onSecondaryFixed: 'secondary10', + onSecondaryFixedVariant: 'secondary30', + tertiaryFixed: 'tertiary90', + tertiaryFixedDim: 'tertiary80', + onTertiaryFixed: 'tertiary10', + onTertiaryFixedVariant: 'tertiary30', + shadow: 'neutral0', + scrim: 'neutral0', + }, }, }; const elevationToTone: Record< 'light' | 'dark', - Record< - 'standard', - Record, PaletteKey> - > + Record, PaletteKey> > = { light: { - standard: { - level1: 'neutral96', - level2: 'neutral94', - level3: 'neutral92', - level4: 'neutral92', - level5: 'neutral90', - }, + level1: 'neutral96', + level2: 'neutral94', + level3: 'neutral92', + level4: 'neutral92', + level5: 'neutral90', }, dark: { - standard: { - level1: 'neutral10', - level2: 'neutral12', - level3: 'neutral17', - level4: 'neutral17', - level5: 'neutral22', - }, + level1: 'neutral10', + level2: 'neutral12', + level3: 'neutral17', + level4: 'neutral17', + level5: 'neutral22', }, }; @@ -156,30 +351,14 @@ const elevationToTone: Record< const withPressedOpacity = (onSurface: string) => color(onSurface).alpha(state.opacity.pressed).rgb().string(); -/** - * Builds the color scheme for a mode and contrast level. - * - * `palette` is only used at `standard` contrast. The `medium` and `high` - * schemes already hold color values, so a custom `palette` is ignored there. - */ +/** Builds the color scheme for a mode and contrast level. */ export function buildScheme( palette: Palette, opts: { mode: 'light' | 'dark'; contrast?: ContrastLevel } ): ThemeColors { const contrast = opts.contrast ?? 'standard'; - - if (contrast !== 'standard') { - const { roles, elevation } = contrastSchemes[opts.mode][contrast]; - - return { - ...roles, - stateLayerPressed: withPressedOpacity(roles.onSurface), - elevation: { level0: 'transparent', ...elevation }, - }; - } - const tones = roleToTone[opts.mode][contrast]; - const elevTones = elevationToTone[opts.mode][contrast]; + const elevTones = elevationToTone[opts.mode]; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const mapped = Object.fromEntries( diff --git a/src/theme/tokens/sys/contrastSchemes.ts b/src/theme/tokens/sys/contrastSchemes.ts deleted file mode 100644 index 30c48f1615..0000000000 --- a/src/theme/tokens/sys/contrastSchemes.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * GENERATED by scripts/generate-contrast-tokens.ts, do not edit by hand. - * Run `yarn generate-contrast-tokens` to regenerate. - * - * MD3 medium and high contrast schemes, seeded from #6750A4. - * - */ -import type { ContrastLevel, ElevationColors, ThemeColors } from '../../types'; - -type GeneratedContrast = Exclude; - -type GeneratedScheme = { - roles: Record< - Exclude, - string - >; - elevation: Record, string>; -}; - -export const contrastSchemes: Record< - 'light' | 'dark', - Record -> = { - light: { - medium: { - roles: { - primary: 'rgba(60, 45, 99, 1)', - primaryContainer: 'rgba(116, 100, 159, 1)', - secondary: 'rgba(57, 51, 71, 1)', - secondaryContainer: 'rgba(113, 106, 128, 1)', - tertiary: 'rgba(80, 43, 56, 1)', - tertiaryContainer: 'rgba(142, 96, 111, 1)', - surface: 'rgba(253, 247, 255, 1)', - surfaceDim: 'rgba(202, 197, 204, 1)', - surfaceBright: 'rgba(253, 247, 255, 1)', - surfaceContainerLowest: 'rgba(255, 255, 255, 1)', - surfaceContainerLow: 'rgba(248, 242, 250, 1)', - surfaceContainer: 'rgba(236, 230, 238, 1)', - surfaceContainerHigh: 'rgba(225, 219, 227, 1)', - surfaceContainerHighest: 'rgba(213, 208, 216, 1)', - surfaceVariant: 'rgba(231, 224, 235, 1)', - background: 'rgba(253, 247, 255, 1)', - error: 'rgba(116, 0, 6, 1)', - errorContainer: 'rgba(207, 44, 39, 1)', - onPrimary: 'rgba(255, 255, 255, 1)', - onPrimaryContainer: 'rgba(255, 255, 255, 1)', - onSecondary: 'rgba(255, 255, 255, 1)', - onSecondaryContainer: 'rgba(255, 255, 255, 1)', - onTertiary: 'rgba(255, 255, 255, 1)', - onTertiaryContainer: 'rgba(255, 255, 255, 1)', - onSurface: 'rgba(18, 16, 22, 1)', - onSurfaceVariant: 'rgba(56, 53, 61, 1)', - onError: 'rgba(255, 255, 255, 1)', - onErrorContainer: 'rgba(255, 255, 255, 1)', - onBackground: 'rgba(29, 27, 32, 1)', - outline: 'rgba(84, 81, 90, 1)', - outlineVariant: 'rgba(111, 107, 117, 1)', - inverseSurface: 'rgba(50, 47, 53, 1)', - inverseOnSurface: 'rgba(245, 239, 247, 1)', - inversePrimary: 'rgba(207, 189, 254, 1)', - primaryFixed: 'rgba(116, 100, 159, 1)', - primaryFixedDim: 'rgba(91, 76, 132, 1)', - onPrimaryFixed: 'rgba(255, 255, 255, 1)', - onPrimaryFixedVariant: 'rgba(255, 255, 255, 1)', - secondaryFixed: 'rgba(113, 106, 128, 1)', - secondaryFixedDim: 'rgba(88, 82, 103, 1)', - onSecondaryFixed: 'rgba(255, 255, 255, 1)', - onSecondaryFixedVariant: 'rgba(255, 255, 255, 1)', - tertiaryFixed: 'rgba(142, 96, 111, 1)', - tertiaryFixedDim: 'rgba(115, 72, 86, 1)', - onTertiaryFixed: 'rgba(255, 255, 255, 1)', - onTertiaryFixedVariant: 'rgba(255, 255, 255, 1)', - shadow: 'rgba(0, 0, 0, 1)', - scrim: 'rgba(0, 0, 0, 1)', - }, - elevation: { - level1: 'rgba(248, 242, 250, 1)', - level2: 'rgba(236, 230, 238, 1)', - level3: 'rgba(225, 219, 227, 1)', - level4: 'rgba(225, 219, 227, 1)', - level5: 'rgba(213, 208, 216, 1)', - }, - }, - high: { - roles: { - primary: 'rgba(49, 34, 89, 1)', - primaryContainer: 'rgba(79, 64, 120, 1)', - secondary: 'rgba(47, 41, 60, 1)', - secondaryContainer: 'rgba(76, 70, 91, 1)', - tertiary: 'rgba(69, 33, 46, 1)', - tertiaryContainer: 'rgba(102, 61, 75, 1)', - surface: 'rgba(253, 247, 255, 1)', - surfaceDim: 'rgba(188, 183, 191, 1)', - surfaceBright: 'rgba(253, 247, 255, 1)', - surfaceContainerLowest: 'rgba(255, 255, 255, 1)', - surfaceContainerLow: 'rgba(245, 239, 247, 1)', - surfaceContainer: 'rgba(230, 224, 233, 1)', - surfaceContainerHigh: 'rgba(216, 210, 218, 1)', - surfaceContainerHighest: 'rgba(202, 197, 204, 1)', - surfaceVariant: 'rgba(231, 224, 235, 1)', - background: 'rgba(253, 247, 255, 1)', - error: 'rgba(96, 0, 4, 1)', - errorContainer: 'rgba(152, 0, 10, 1)', - onPrimary: 'rgba(255, 255, 255, 1)', - onPrimaryContainer: 'rgba(255, 255, 255, 1)', - onSecondary: 'rgba(255, 255, 255, 1)', - onSecondaryContainer: 'rgba(255, 255, 255, 1)', - onTertiary: 'rgba(255, 255, 255, 1)', - onTertiaryContainer: 'rgba(255, 255, 255, 1)', - onSurface: 'rgba(0, 0, 0, 1)', - onSurfaceVariant: 'rgba(0, 0, 0, 1)', - onError: 'rgba(255, 255, 255, 1)', - onErrorContainer: 'rgba(255, 255, 255, 1)', - onBackground: 'rgba(29, 27, 32, 1)', - outline: 'rgba(46, 43, 51, 1)', - outlineVariant: 'rgba(75, 72, 81, 1)', - inverseSurface: 'rgba(50, 47, 53, 1)', - inverseOnSurface: 'rgba(255, 255, 255, 1)', - inversePrimary: 'rgba(207, 189, 254, 1)', - primaryFixed: 'rgba(79, 64, 120, 1)', - primaryFixedDim: 'rgba(56, 41, 96, 1)', - onPrimaryFixed: 'rgba(255, 255, 255, 1)', - onPrimaryFixedVariant: 'rgba(255, 255, 255, 1)', - secondaryFixed: 'rgba(76, 70, 91, 1)', - secondaryFixedDim: 'rgba(53, 48, 67, 1)', - onSecondaryFixed: 'rgba(255, 255, 255, 1)', - onSecondaryFixedVariant: 'rgba(255, 255, 255, 1)', - tertiaryFixed: 'rgba(102, 61, 75, 1)', - tertiaryFixedDim: 'rgba(76, 39, 52, 1)', - onTertiaryFixed: 'rgba(255, 255, 255, 1)', - onTertiaryFixedVariant: 'rgba(255, 255, 255, 1)', - shadow: 'rgba(0, 0, 0, 1)', - scrim: 'rgba(0, 0, 0, 1)', - }, - elevation: { - level1: 'rgba(245, 239, 247, 1)', - level2: 'rgba(230, 224, 233, 1)', - level3: 'rgba(216, 210, 218, 1)', - level4: 'rgba(216, 210, 218, 1)', - level5: 'rgba(202, 197, 204, 1)', - }, - }, - }, - dark: { - medium: { - roles: { - primary: 'rgba(227, 214, 255, 1)', - primaryContainer: 'rgba(152, 135, 197, 1)', - secondary: 'rgba(226, 216, 242, 1)', - secondaryContainer: 'rgba(149, 141, 164, 1)', - tertiary: 'rgba(255, 208, 221, 1)', - tertiaryContainer: 'rgba(181, 131, 146, 1)', - surface: 'rgba(20, 18, 24, 1)', - surfaceDim: 'rgba(20, 18, 24, 1)', - surfaceBright: 'rgba(70, 67, 74, 1)', - surfaceContainerLowest: 'rgba(8, 7, 11, 1)', - surfaceContainerLow: 'rgba(31, 29, 34, 1)', - surfaceContainer: 'rgba(41, 39, 45, 1)', - surfaceContainerHigh: 'rgba(52, 49, 56, 1)', - surfaceContainerHighest: 'rgba(63, 60, 67, 1)', - surfaceVariant: 'rgba(73, 69, 78, 1)', - background: 'rgba(20, 18, 24, 1)', - error: 'rgba(255, 210, 204, 1)', - errorContainer: 'rgba(255, 84, 73, 1)', - onPrimary: 'rgba(43, 27, 82, 1)', - onPrimaryContainer: 'rgba(0, 0, 0, 1)', - onSecondary: 'rgba(40, 35, 54, 1)', - onSecondaryContainer: 'rgba(0, 0, 0, 1)', - onTertiary: 'rgba(61, 26, 39, 1)', - onTertiaryContainer: 'rgba(0, 0, 0, 1)', - onSurface: 'rgba(255, 255, 255, 1)', - onSurfaceVariant: 'rgba(224, 218, 229, 1)', - onError: 'rgba(84, 0, 3, 1)', - onErrorContainer: 'rgba(0, 0, 0, 1)', - onBackground: 'rgba(230, 224, 233, 1)', - outline: 'rgba(181, 176, 187, 1)', - outlineVariant: 'rgba(147, 142, 153, 1)', - inverseSurface: 'rgba(230, 224, 233, 1)', - inverseOnSurface: 'rgba(43, 41, 47, 1)', - inversePrimary: 'rgba(78, 63, 119, 1)', - primaryFixed: 'rgba(233, 221, 255, 1)', - primaryFixedDim: 'rgba(207, 189, 254, 1)', - onPrimaryFixed: 'rgba(22, 3, 61, 1)', - onPrimaryFixedVariant: 'rgba(60, 45, 99, 1)', - secondaryFixed: 'rgba(232, 222, 248, 1)', - secondaryFixedDim: 'rgba(203, 194, 219, 1)', - onSecondaryFixed: 'rgba(19, 14, 32, 1)', - onSecondaryFixedVariant: 'rgba(57, 51, 71, 1)', - tertiaryFixed: 'rgba(255, 217, 227, 1)', - tertiaryFixedDim: 'rgba(239, 184, 200, 1)', - onTertiaryFixed: 'rgba(36, 6, 19, 1)', - onTertiaryFixedVariant: 'rgba(80, 43, 56, 1)', - shadow: 'rgba(0, 0, 0, 1)', - scrim: 'rgba(0, 0, 0, 1)', - }, - elevation: { - level1: 'rgba(31, 29, 34, 1)', - level2: 'rgba(41, 39, 45, 1)', - level3: 'rgba(52, 49, 56, 1)', - level4: 'rgba(52, 49, 56, 1)', - level5: 'rgba(63, 60, 67, 1)', - }, - }, - high: { - roles: { - primary: 'rgba(245, 237, 255, 1)', - primaryContainer: 'rgba(203, 185, 250, 1)', - secondary: 'rgba(245, 237, 255, 1)', - secondaryContainer: 'rgba(200, 191, 216, 1)', - tertiary: 'rgba(255, 235, 239, 1)', - tertiaryContainer: 'rgba(235, 180, 196, 1)', - surface: 'rgba(20, 18, 24, 1)', - surfaceDim: 'rgba(20, 18, 24, 1)', - surfaceBright: 'rgba(82, 79, 85, 1)', - surfaceContainerLowest: 'rgba(0, 0, 0, 1)', - surfaceContainerLow: 'rgba(33, 31, 36, 1)', - surfaceContainer: 'rgba(50, 47, 53, 1)', - surfaceContainerHigh: 'rgba(61, 58, 65, 1)', - surfaceContainerHighest: 'rgba(72, 70, 76, 1)', - surfaceVariant: 'rgba(73, 69, 78, 1)', - background: 'rgba(20, 18, 24, 1)', - error: 'rgba(255, 236, 233, 1)', - errorContainer: 'rgba(255, 174, 164, 1)', - onPrimary: 'rgba(0, 0, 0, 1)', - onPrimaryContainer: 'rgba(15, 0, 51, 1)', - onSecondary: 'rgba(0, 0, 0, 1)', - onSecondaryContainer: 'rgba(13, 8, 26, 1)', - onTertiary: 'rgba(0, 0, 0, 1)', - onTertiaryContainer: 'rgba(29, 2, 13, 1)', - onSurface: 'rgba(255, 255, 255, 1)', - onSurfaceVariant: 'rgba(255, 255, 255, 1)', - onError: 'rgba(0, 0, 0, 1)', - onErrorContainer: 'rgba(34, 0, 1, 1)', - onBackground: 'rgba(230, 224, 233, 1)', - outline: 'rgba(244, 238, 249, 1)', - outlineVariant: 'rgba(198, 192, 203, 1)', - inverseSurface: 'rgba(230, 224, 233, 1)', - inverseOnSurface: 'rgba(0, 0, 0, 1)', - inversePrimary: 'rgba(78, 63, 119, 1)', - primaryFixed: 'rgba(233, 221, 255, 1)', - primaryFixedDim: 'rgba(207, 189, 254, 1)', - onPrimaryFixed: 'rgba(0, 0, 0, 1)', - onPrimaryFixedVariant: 'rgba(22, 3, 61, 1)', - secondaryFixed: 'rgba(232, 222, 248, 1)', - secondaryFixedDim: 'rgba(203, 194, 219, 1)', - onSecondaryFixed: 'rgba(0, 0, 0, 1)', - onSecondaryFixedVariant: 'rgba(19, 14, 32, 1)', - tertiaryFixed: 'rgba(255, 217, 227, 1)', - tertiaryFixedDim: 'rgba(239, 184, 200, 1)', - onTertiaryFixed: 'rgba(0, 0, 0, 1)', - onTertiaryFixedVariant: 'rgba(36, 6, 19, 1)', - shadow: 'rgba(0, 0, 0, 1)', - scrim: 'rgba(0, 0, 0, 1)', - }, - elevation: { - level1: 'rgba(33, 31, 36, 1)', - level2: 'rgba(50, 47, 53, 1)', - level3: 'rgba(61, 58, 65, 1)', - level4: 'rgba(61, 58, 65, 1)', - level5: 'rgba(72, 70, 76, 1)', - }, - }, - }, -} as const; diff --git a/yarn.lock b/yarn.lock index 2254fd30f3..dcc0fa4cac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3988,13 +3988,6 @@ __metadata: languageName: node linkType: hard -"@material/material-color-utilities@npm:0.3.0": - version: 0.3.0 - resolution: "@material/material-color-utilities@npm:0.3.0" - checksum: 10c0/3bef025428b893f2acc9e9e2bd186363a60b7c0836fe43c78222e29fe67dc579618e844f0661a20657ed9f7fd8b94fd43a2961892894d0b6a2ba5264ce2673f8 - languageName: node - linkType: hard - "@mdx-js/mdx@npm:^0.20.3": version: 0.20.3 resolution: "@mdx-js/mdx@npm:0.20.3" @@ -18209,7 +18202,6 @@ __metadata: "@commitlint/config-conventional": "npm:^8.3.4" "@eslint/js": "npm:9.39.4" "@jest/globals": "npm:^29.7.0" - "@material/material-color-utilities": "npm:0.3.0" "@react-native-vector-icons/material-design-icons": "npm:^12.0.0" "@react-native/babel-preset": "npm:^0.85.3" "@react-native/jest-preset": "npm:^0.85.3" From 6d4e49b7dbcaf40b8ba05d78f00074516376ad1d Mon Sep 17 00:00:00 2001 From: Hristo Totov Date: Thu, 10 Sep 2026 14:56:57 +0300 Subject: [PATCH 3/3] fix(): adress latest cr comments --- docs/6.x/docs/guides/theming.mdx | 59 ++++++------ example/src/PreferencesContext.tsx | 4 +- example/src/index.tsx | 50 ++++++++-- .../__snapshots__/ListSection.test.tsx.snap | 3 - src/core/PaperProvider.tsx | 34 +------ src/core/__tests__/PaperProvider.test.tsx | 67 -------------- src/core/__tests__/theming.test.tsx | 12 ++- src/index.tsx | 7 +- src/theme/__tests__/contrast.test.ts | 92 ++++++++++++++----- src/theme/schemes/DarkTheme.tsx | 28 +++++- src/theme/schemes/DynamicTheme.android.tsx | 30 +++--- src/theme/schemes/DynamicTheme.tsx | 15 +-- src/theme/schemes/LightTheme.tsx | 28 +++++- src/theme/schemes/createTheme.ts | 28 ------ src/theme/schemes/index.ts | 19 +++- src/theme/tokens/sys/color.ts | 12 +-- src/theme/types/theme.ts | 1 - 17 files changed, 243 insertions(+), 246 deletions(-) delete mode 100644 src/theme/schemes/createTheme.ts diff --git a/docs/6.x/docs/guides/theming.mdx b/docs/6.x/docs/guides/theming.mdx index bb9ab156c5..6661f01e93 100644 --- a/docs/6.x/docs/guides/theming.mdx +++ b/docs/6.x/docs/guides/theming.mdx @@ -65,7 +65,6 @@ You can change the theme prop dynamically and all the components will automatica A theme usually contains the following properties: - `dark` (`boolean`): whether this is a dark theme or light theme. -- `contrast` (`'standard' | 'medium' | 'high'`): the active MD3 contrast level (see [Contrast levels](#contrast-levels)). - `version`: Material You (MD3); kept for compatibility and normalized to `3` by `PaperProvider` - `mode` (`'adaptive' | 'exact'`): color mode for dark theme (See [Dark Theme](#dark-theme)). - `roundness` (`number`): roundness of common elements, such as buttons. @@ -247,59 +246,59 @@ export default function Main() { ## Contrast levels -Material Design 3 defines three contrast levels - `standard`, `medium` and `high`. The higher levels increase the contrast between foreground and background roles, which helps users with low vision and improves readability in bright environments. +Material Design 3 defines three contrast levels, `standard`, `medium` and `high`. The higher levels increase the contrast between text and background colors, which helps users with low vision and makes the app easier to read in bright light. -Set the level with the `contrast` prop on `PaperProvider`. It defaults to `standard`, so existing apps are unaffected. +Each level is an exported theme that you pass to `PaperProvider`, the same way as the default themes: ```js import * as React from 'react'; -import { PaperProvider } from 'react-native-paper'; +import { PaperProvider, HighContrastLightTheme } from 'react-native-paper'; export default function Main() { return ( - + ); } ``` -Use the `contrast` prop rather than passing a pre-built theme: `PaperProvider` only follows the system light/dark setting while no `theme` prop is given, so selecting contrast through `theme` would also opt you out of automatic dark mode. - -The `medium` and `high` schemes meet the WCAG contrast ratios of 4.5:1 and 7:1 respectively for every foreground/background role pair. +The available themes are: -The active level is readable from the theme: +- `LightTheme` and `DarkTheme` +- `MediumContrastLightTheme` and `MediumContrastDarkTheme` +- `HighContrastLightTheme` and `HighContrastDarkTheme` -```js -const { contrast } = useTheme(); -``` +The `medium` and `high` schemes meet the WCAG contrast ratios of 4.5:1 and 7:1 respectively for every text and background role pair. -To build a theme object directly, for example to hand to `adaptNavigationTheme`, use `createTheme` or `getTheme`: +Note that passing a `theme` turns off automatic system dark mode, so pick the theme yourself when you follow the system setting: ```js -import { createTheme, getTheme } from 'react-native-paper'; +import { useColorScheme } from 'react-native'; +import { + PaperProvider, + HighContrastDarkTheme, + HighContrastLightTheme, +} from 'react-native-paper'; + +export default function Main() { + const isDarkMode = useColorScheme() === 'dark'; -const highContrastDark = createTheme({ dark: true, contrast: 'high' }); -const sameThing = getTheme(true, 'high'); + return ( + + + + ); +} ``` ### Contrast and dynamic colors -Android does not expose a contrast-adjusted version of its system palette. Applying the standard-contrast system colors at a raised contrast level would quietly undercut the level you asked for, so at `medium` and `high` the dynamic palette is skipped in favour of the contrast-correct scheme. - -`getDynamicTheme` applies this rule for you, and `isDynamicColorSupportedAtContrast` reports whether dynamic colors will actually be used: +There are matching dynamic themes: `MediumContrastDynamicLightTheme`, `HighContrastDynamicLightTheme`, `MediumContrastDynamicDarkTheme` and `HighContrastDynamicDarkTheme`. -```js -import { - getDynamicTheme, - isDynamicColorSupportedAtContrast, -} from 'react-native-paper'; - -// Falls back to the high-contrast scheme, dynamic colors included only at 'standard'. -const theme = getDynamicTheme(isDarkMode, 'high'); - -isDynamicColorSupportedAtContrast('high'); // false -``` +Android exposes no contrast adjusted version of its system palette, so these fall back to the static schemes. Using the standard contrast system colors at a raised level would quietly lower the contrast you asked for. ## Adapting React Navigation theme diff --git a/example/src/PreferencesContext.tsx b/example/src/PreferencesContext.tsx index 875fa8e4e9..e6d2ca1acd 100644 --- a/example/src/PreferencesContext.tsx +++ b/example/src/PreferencesContext.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; -import type { ContrastLevel, Theme } from 'react-native-paper'; +import type { Theme } from 'react-native-paper'; + +type ContrastLevel = 'standard' | 'medium' | 'high'; export const PreferencesContext = React.createContext<{ toggleTheme: () => void; diff --git a/example/src/index.tsx b/example/src/index.tsx index 98c93a12f3..ec7b451f21 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -13,10 +13,19 @@ import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; import * as Updates from 'expo-updates'; import { + DarkTheme, + DynamicDarkTheme, + DynamicLightTheme, + HighContrastDarkTheme, + HighContrastDynamicDarkTheme, + HighContrastDynamicLightTheme, + HighContrastLightTheme, + LightTheme, + MediumContrastDarkTheme, + MediumContrastDynamicDarkTheme, + MediumContrastDynamicLightTheme, + MediumContrastLightTheme, PaperProvider, - getDynamicTheme, - createTheme, - type ContrastLevel, } from 'react-native-paper'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -30,6 +39,34 @@ import { createConfiguredFontTheme, } from '../utils/themes'; +type ContrastLevel = 'standard' | 'medium' | 'high'; + +const THEMES = { + light: { + standard: LightTheme, + medium: MediumContrastLightTheme, + high: HighContrastLightTheme, + }, + dark: { + standard: DarkTheme, + medium: MediumContrastDarkTheme, + high: HighContrastDarkTheme, + }, +}; + +const DYNAMIC_THEMES = { + light: { + standard: DynamicLightTheme, + medium: MediumContrastDynamicLightTheme, + high: HighContrastDynamicLightTheme, + }, + dark: { + standard: DynamicDarkTheme, + medium: MediumContrastDynamicDarkTheme, + high: HighContrastDynamicDarkTheme, + }, +}; + const PERSISTENCE_KEY = 'NAVIGATION_STATE'; const PREFERENCES_KEY = 'APP_PREFERENCES'; @@ -98,10 +135,9 @@ export default function PaperExample() { const [rippleEffectEnabled, setRippleEffectEnabled] = React.useState(true); const [contrast, setContrast] = React.useState('standard'); - const theme = - dynamicThemeSupported && shouldUseDynamicTheme - ? getDynamicTheme(isDarkMode, contrast) - : createTheme({ dark: isDarkMode, contrast }); + const themes = + dynamicThemeSupported && shouldUseDynamicTheme ? DYNAMIC_THEMES : THEMES; + const theme = themes[isDarkMode ? 'dark' : 'light'][contrast]; const direction = rtl ? 'rtl' : 'ltr'; diff --git a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap index fbd6aa580b..a64f2e3662 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -74,7 +74,6 @@ exports[`renders list section with custom title style 1`] = ` "tertiaryFixed": "rgba(255, 216, 228, 1)", "tertiaryFixedDim": "rgba(239, 184, 200, 1)", }, - "contrast": "standard", "dark": false, "elevation": { "level0": 0, @@ -835,7 +834,6 @@ exports[`renders list section with subheader 1`] = ` "tertiaryFixed": "rgba(255, 216, 228, 1)", "tertiaryFixedDim": "rgba(239, 184, 200, 1)", }, - "contrast": "standard", "dark": false, "elevation": { "level0": 0, @@ -1594,7 +1592,6 @@ exports[`renders list section without subheader 1`] = ` "tertiaryFixed": "rgba(255, 216, 228, 1)", "tertiaryFixedDim": "rgba(239, 184, 200, 1)", }, - "contrast": "standard", "dark": false, "elevation": { "level0": 0, diff --git a/src/core/PaperProvider.tsx b/src/core/PaperProvider.tsx index 363070dca4..43e61453cc 100644 --- a/src/core/PaperProvider.tsx +++ b/src/core/PaperProvider.tsx @@ -14,22 +14,7 @@ import MaterialCommunityIcon from '../components/MaterialCommunityIcon'; import PortalHost from '../components/Portal/PortalHost'; import { ReduceMotionContext } from '../theme/accessibility/ReduceMotionContext'; import { DarkTheme, LightTheme } from '../theme/schemes'; -import { createTheme } from '../theme/schemes/createTheme'; -import type { ContrastLevel, Theme, ThemeProp } from '../theme/types'; - -// Built once so that switching contrast does not rebuild a scheme -const contrastThemes: Record<'light' | 'dark', Record> = { - light: { - standard: LightTheme, - medium: createTheme({ dark: false, contrast: 'medium' }), - high: createTheme({ dark: false, contrast: 'high' }), - }, - dark: { - standard: DarkTheme, - medium: createTheme({ dark: true, contrast: 'medium' }), - high: createTheme({ dark: true, contrast: 'high' }), - }, -}; +import type { ThemeProp } from '../theme/types'; export type Props = { children: React.ReactNode; @@ -37,27 +22,17 @@ export type Props = { settings?: Settings; direction?: Direction; reduceMotion?: ReduceMotionPreference; - /** - * MD3 contrast level. `medium` and `high` raise color contrast to make the - * app easier to read. Unlike `theme`, setting this keeps automatic system - * dark mode working. - * @default 'standard' - */ - contrast?: ContrastLevel; }; const PaperProvider = (props: Props) => { - const { reduceMotion = 'auto', contrast } = props; + const { reduceMotion = 'auto' } = props; const colorScheme = useSystemColorScheme(!props.theme); const resolvedReduceMotion = useResolvedReduceMotion(reduceMotion); const theme = React.useMemo(() => { const isDark = props.theme?.dark ?? colorScheme === 'dark'; - // The prop wins over a level set on a custom theme object - const level = contrast ?? props.theme?.contrast ?? 'standard'; - // `level` is the scheme we picked, `theme.colors` still override it - const base = contrastThemes[isDark ? 'dark' : 'light'][level]; + const base = isDark ? DarkTheme : LightTheme; const scale = resolvedReduceMotion ? 0 : (props.theme?.animation?.scale ?? 1); @@ -65,11 +40,10 @@ const PaperProvider = (props: Props) => { return { ...base, ...props.theme, - contrast: level, colors: { ...base.colors, ...props.theme?.colors }, animation: { ...props.theme?.animation, scale }, }; - }, [colorScheme, contrast, props.theme, resolvedReduceMotion]); + }, [colorScheme, props.theme, resolvedReduceMotion]); const { children, settings } = props; diff --git a/src/core/__tests__/PaperProvider.test.tsx b/src/core/__tests__/PaperProvider.test.tsx index 0361e4d714..e1f5aa9cbd 100644 --- a/src/core/__tests__/PaperProvider.test.tsx +++ b/src/core/__tests__/PaperProvider.test.tsx @@ -12,7 +12,6 @@ import { act, render, screen } from '@testing-library/react-native'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { DarkTheme, DynamicLightTheme, LightTheme } from '../../theme/schemes'; -import { createTheme } from '../../theme/schemes/createTheme'; import type { ThemeProp } from '../../theme/types'; import PaperProvider from '../PaperProvider'; import { useTheme } from '../theming'; @@ -330,70 +329,4 @@ describe('PaperProvider', () => { customTheme ); }); - - it('applies the contrast prop without a theme prop', async () => { - mockAppearance(); - await render( - - - - ); - - // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. - const theme = screen.getByTestId('provider-child-view').props.theme; - - expect(theme).toStrictEqual(createTheme({ dark: false, contrast: 'high' })); - expect(theme.contrast).toBe('high'); - expect(theme.colors.primary).not.toBe(LightTheme.colors.primary); - }); - - it('keeps following the system color scheme when only contrast is set', async () => { - mockAppearance(); - await render( - - - - ); - - expect( - // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. - screen.getByTestId('provider-child-view').props.theme - ).toStrictEqual(createTheme({ dark: false, contrast: 'medium' })); - - await act(() => Appearance.__internalListeners[0]({ colorScheme: 'dark' })); - - expect( - // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. - screen.getByTestId('provider-child-view').props.theme - ).toStrictEqual(createTheme({ dark: true, contrast: 'medium' })); - }); - - it('defaults to standard contrast', async () => { - mockAppearance(); - await render(createProvider()); - - expect( - // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. - screen.getByTestId('provider-child-view').props.theme.contrast - ).toBe('standard'); - }); - - it('lets the contrast prop win over a theme declaring its own level', async () => { - mockAppearance(); - await render( - - - - ); - - // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. - const theme = screen.getByTestId('provider-child-view').props.theme; - - expect(theme.contrast).toBe('standard'); - expect(theme.colors.primary).toBe(LightTheme.colors.primary); - }); }); diff --git a/src/core/__tests__/theming.test.tsx b/src/core/__tests__/theming.test.tsx index bf1e2f29cf..56282a50dd 100644 --- a/src/core/__tests__/theming.test.tsx +++ b/src/core/__tests__/theming.test.tsx @@ -1,7 +1,11 @@ import { describe, expect, it } from '@jest/globals'; -import { DarkTheme, LightTheme } from '../../theme/schemes'; -import { createTheme } from '../../theme/schemes/createTheme'; +import { + DarkTheme, + HighContrastDarkTheme, + HighContrastLightTheme, + LightTheme, +} from '../../theme/schemes'; import { adaptNavigationTheme } from '../theming'; const NavigationLightTheme = { @@ -276,8 +280,8 @@ describe('adaptNavigationTheme', () => { }); it('adapts the colors of a raised-contrast material theme', () => { - const materialLight = createTheme({ dark: false, contrast: 'high' }); - const materialDark = createTheme({ dark: true, contrast: 'high' }); + const materialLight = HighContrastLightTheme; + const materialDark = HighContrastDarkTheme; const { LightTheme: navLight, DarkTheme: navDark } = adaptNavigationTheme({ reactNavigationLight: NavigationLightTheme, diff --git a/src/index.tsx b/src/index.tsx index 56f097d339..f46d8e22d8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -147,9 +147,4 @@ export type { Props as SegmentedButtonsProps } from './components/SegmentedButto export type { Props as ListImageProps } from './components/List/ListImage'; export type { Props as TooltipProps } from './components/Tooltip/Tooltip'; -export { - type TypescaleKey, - type Theme, - type Elevation, - type ContrastLevel, -} from './theme/types'; +export { type TypescaleKey, type Theme, type Elevation } from './theme/types'; diff --git a/src/theme/__tests__/contrast.test.ts b/src/theme/__tests__/contrast.test.ts index 61df9830c6..76df4d7d39 100644 --- a/src/theme/__tests__/contrast.test.ts +++ b/src/theme/__tests__/contrast.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from '@jest/globals'; import color from 'color'; -import { createTheme } from '../schemes/createTheme'; -import { DarkTheme } from '../schemes/DarkTheme'; -import { LightTheme } from '../schemes/LightTheme'; +import { + DarkTheme, + HighContrastDarkTheme, + HighContrastLightTheme, + LightTheme, + MediumContrastDarkTheme, + MediumContrastLightTheme, +} from '../schemes'; import { palette } from '../tokens/ref/palette'; import { buildScheme } from '../tokens/sys/color'; import type { ContrastLevel, ThemeColors } from '../types'; @@ -11,6 +16,19 @@ import type { ContrastLevel, ThemeColors } from '../types'; const MODES = ['light', 'dark'] as const; const NON_STANDARD = ['medium', 'high'] as const satisfies ContrastLevel[]; +const THEMES = { + light: { + standard: LightTheme, + medium: MediumContrastLightTheme, + high: HighContrastLightTheme, + }, + dark: { + standard: DarkTheme, + medium: MediumContrastDarkTheme, + high: HighContrastDarkTheme, + }, +} as const; + /** * Text and background role pairs that MD3 requires to be readable. * @see https://m3.material.io/styles/color/roles @@ -33,6 +51,15 @@ const CONTRAST_PAIRS: [keyof ThemeColors, keyof ThemeColors][] = [ ['onPrimaryFixed', 'primaryFixed'], ['onSecondaryFixed', 'secondaryFixed'], ['onTertiaryFixed', 'tertiaryFixed'], + ['onPrimaryFixed', 'primaryFixedDim'], + ['onSecondaryFixed', 'secondaryFixedDim'], + ['onTertiaryFixed', 'tertiaryFixedDim'], + ['onPrimaryFixedVariant', 'primaryFixedDim'], + ['onSecondaryFixedVariant', 'secondaryFixedDim'], + ['onTertiaryFixedVariant', 'tertiaryFixedDim'], + ['onPrimaryFixedVariant', 'primaryFixed'], + ['onSecondaryFixedVariant', 'secondaryFixed'], + ['onTertiaryFixedVariant', 'tertiaryFixed'], ]; /** WCAG 2.x minimum ratio per MD3 contrast level. */ @@ -74,7 +101,7 @@ describe('contrast levels', () => { }); it.each(NON_STANDARD)('meets WCAG contrast targets at %s', (contrast) => { - const { colors } = createTheme({ dark: mode === 'dark', contrast }); + const { colors } = THEMES[mode][contrast]; const target = WCAG_TARGET[contrast]; const failures = CONTRAST_PAIRS.filter( @@ -91,9 +118,8 @@ describe('contrast levels', () => { it.each(NON_STANDARD)( 'raises contrast above standard at %s', (contrast) => { - const isDark = mode === 'dark'; - const standard = createTheme({ dark: isDark }).colors; - const raised = createTheme({ dark: isDark, contrast }).colors; + const standard = THEMES[mode].standard.colors; + const raised = THEMES[mode][contrast].colors; expect(ratio(raised.onPrimary, raised.primary)).toBeGreaterThan( ratio(standard.onPrimary, standard.primary) @@ -103,7 +129,7 @@ describe('contrast levels', () => { }); it('derives the pressed state layer from the scheme onSurface', () => { - const { colors } = createTheme({ contrast: 'high' }); + const { colors } = HighContrastLightTheme; expect(colors.stateLayerPressed).toBe( asColor(colors.onSurface).alpha(0.1).rgb().string() @@ -113,35 +139,51 @@ describe('contrast levels', () => { ); }); - it('keeps the fixed roles the same at every contrast level', () => { - // MD3 defines the *Fixed roles as stable across contrast levels. + it('keeps the fixed surfaces the same at every contrast level', () => { + // The fixed surfaces stay put so they can be shared across light and dark. + // Their `on*FixedVariant` foregrounds still darken to hold the ratio. let checked = 0; MODES.forEach((mode) => { - const isDark = mode === 'dark'; - const standard = createTheme({ dark: isDark }).colors; + const standard = THEMES[mode].standard.colors; NON_STANDARD.forEach((contrast) => { - const raised = createTheme({ dark: isDark, contrast }).colors; + const raised = THEMES[mode][contrast].colors; - const fixedOf = (colors: ThemeColors) => - Object.entries(colors).filter(([role]) => role.includes('Fixed')); + const surfacesOf = (colors: ThemeColors) => + Object.entries(colors).filter( + ([role]) => role.includes('Fixed') && !role.startsWith('on') + ); - const before = fixedOf(standard); + const before = surfacesOf(standard); checked += before.length; - expect(fixedOf(raised)).toStrictEqual(before); + expect(surfacesOf(raised)).toStrictEqual(before); }); }); expect(checked).toBeGreaterThan(0); }); + it('keeps the fixed foregrounds readable as contrast rises', () => { + MODES.forEach((mode) => { + const standard = THEMES[mode].standard.colors; + const high = THEMES[mode].high.colors; + + // The variant foreground darkens so it clears 7:1 on the dim surface. + expect( + ratio(high.onPrimaryFixedVariant, high.primaryFixedDim) + ).toBeGreaterThan( + ratio(standard.onPrimaryFixedVariant, standard.primaryFixedDim) + ); + }); + }); + it('keeps a container distinct from its base role', () => { // A container collapsing onto its base role means the scheme has clipped. NON_STANDARD.forEach((contrast) => { MODES.forEach((mode) => { - const { colors } = createTheme({ dark: mode === 'dark', contrast }); + const { colors } = THEMES[mode][contrast]; expect(colors.primaryContainer).not.toBe(colors.primary); expect(colors.secondaryContainer).not.toBe(colors.secondary); @@ -154,18 +196,18 @@ describe('contrast levels', () => { it('keeps elevation level0 transparent', () => { NON_STANDARD.forEach((contrast) => { - expect(createTheme({ contrast }).colors.elevation.level0).toBe( + expect(THEMES.light[contrast].colors.elevation.level0).toBe( 'transparent' ); }); }); - it('defaults to standard, leaving the built-in themes unchanged', () => { - expect(createTheme({ dark: false }).colors).toStrictEqual( - LightTheme.colors + it('leaves the built-in themes at standard contrast', () => { + expect(LightTheme.colors).toStrictEqual( + buildScheme(palette, { mode: 'light' }) + ); + expect(DarkTheme.colors).toStrictEqual( + buildScheme(palette, { mode: 'dark' }) ); - expect(createTheme({ dark: true }).colors).toStrictEqual(DarkTheme.colors); - expect(LightTheme.contrast).toBe('standard'); - expect(DarkTheme.contrast).toBe('standard'); }); }); diff --git a/src/theme/schemes/DarkTheme.tsx b/src/theme/schemes/DarkTheme.tsx index 3c194559f8..3ee90f6e94 100644 --- a/src/theme/schemes/DarkTheme.tsx +++ b/src/theme/schemes/DarkTheme.tsx @@ -1,4 +1,28 @@ -import { createTheme } from './createTheme'; +import { themeDefaults } from './base'; +import { tokens } from '../tokens'; +import { buildScheme } from '../tokens/sys/color'; import type { Theme } from '../types'; -export const DarkTheme: Theme = createTheme({ dark: true }); +export const DarkTheme: Theme = { + ...themeDefaults, + dark: true, + colors: buildScheme(tokens.md.ref.palette, { mode: 'dark' }), +}; + +export const MediumContrastDarkTheme: Theme = { + ...themeDefaults, + dark: true, + colors: buildScheme(tokens.md.ref.palette, { + mode: 'dark', + contrast: 'medium', + }), +}; + +export const HighContrastDarkTheme: Theme = { + ...themeDefaults, + dark: true, + colors: buildScheme(tokens.md.ref.palette, { + mode: 'dark', + contrast: 'high', + }), +}; diff --git a/src/theme/schemes/DynamicTheme.android.tsx b/src/theme/schemes/DynamicTheme.android.tsx index 2b86b5162c..60a1850f11 100644 --- a/src/theme/schemes/DynamicTheme.android.tsx +++ b/src/theme/schemes/DynamicTheme.android.tsx @@ -1,10 +1,9 @@ import { Platform, PlatformColor, type ColorValue } from 'react-native'; -import { createTheme } from './createTheme'; import { DarkTheme } from './DarkTheme'; import { LightTheme } from './LightTheme'; import { Palette } from '../tokens'; -import type { ContrastLevel, Theme, ThemeColors } from '../types'; +import type { Theme, ThemeColors } from '../types'; const apiLevel = Platform.OS === 'android' ? Platform.Version : null; @@ -491,21 +490,16 @@ export const DynamicDarkTheme: Theme = { colors: { ...DarkTheme.colors, ...darkDynamicColors }, }; -/** Android has no high contrast version of its system colors, so dynamic - * color is only used at `standard` contrast. */ -export const isDynamicColorSupportedAtContrast = (contrast: ContrastLevel) => - isDynamicColorSupported && contrast === 'standard'; - /** - * Dynamic theme for a scheme and contrast level. + * 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 const getDynamicTheme = ( - isDark: boolean, - contrast: ContrastLevel = 'standard' -): Theme => { - if (!isDynamicColorSupportedAtContrast(contrast)) { - return createTheme({ dark: isDark, contrast }); - } - - return isDark ? DynamicDarkTheme : DynamicLightTheme; -}; +export { + MediumContrastLightTheme as MediumContrastDynamicLightTheme, + HighContrastLightTheme as HighContrastDynamicLightTheme, +} from './LightTheme'; +export { + MediumContrastDarkTheme as MediumContrastDynamicDarkTheme, + HighContrastDarkTheme as HighContrastDynamicDarkTheme, +} from './DarkTheme'; diff --git a/src/theme/schemes/DynamicTheme.tsx b/src/theme/schemes/DynamicTheme.tsx index ab0e4aa0c4..0f13ef0843 100644 --- a/src/theme/schemes/DynamicTheme.tsx +++ b/src/theme/schemes/DynamicTheme.tsx @@ -1,15 +1,8 @@ -import { createTheme } from './createTheme'; -import type { ContrastLevel, Theme } from '../types'; - export { DarkTheme as DynamicDarkTheme } from './DarkTheme'; export { LightTheme as DynamicLightTheme } from './LightTheme'; +export { MediumContrastLightTheme as MediumContrastDynamicLightTheme } from './LightTheme'; +export { HighContrastLightTheme as HighContrastDynamicLightTheme } from './LightTheme'; +export { MediumContrastDarkTheme as MediumContrastDynamicDarkTheme } from './DarkTheme'; +export { HighContrastDarkTheme as HighContrastDynamicDarkTheme } from './DarkTheme'; export const isDynamicColorSupported = false; - -export const isDynamicColorSupportedAtContrast = (_contrast: ContrastLevel) => - false; - -export const getDynamicTheme = ( - isDark: boolean, - contrast: ContrastLevel = 'standard' -): Theme => createTheme({ dark: isDark, contrast }); diff --git a/src/theme/schemes/LightTheme.tsx b/src/theme/schemes/LightTheme.tsx index 7fde956331..2d2e6bc534 100644 --- a/src/theme/schemes/LightTheme.tsx +++ b/src/theme/schemes/LightTheme.tsx @@ -1,4 +1,28 @@ -import { createTheme } from './createTheme'; +import { themeDefaults } from './base'; +import { tokens } from '../tokens'; +import { buildScheme } from '../tokens/sys/color'; import type { Theme } from '../types'; -export const LightTheme: Theme = createTheme({ dark: false }); +export const LightTheme: Theme = { + ...themeDefaults, + dark: false, + colors: buildScheme(tokens.md.ref.palette, { mode: 'light' }), +}; + +export const MediumContrastLightTheme: Theme = { + ...themeDefaults, + dark: false, + colors: buildScheme(tokens.md.ref.palette, { + mode: 'light', + contrast: 'medium', + }), +}; + +export const HighContrastLightTheme: Theme = { + ...themeDefaults, + dark: false, + colors: buildScheme(tokens.md.ref.palette, { + mode: 'light', + contrast: 'high', + }), +}; diff --git a/src/theme/schemes/createTheme.ts b/src/theme/schemes/createTheme.ts deleted file mode 100644 index 6aed8d2572..0000000000 --- a/src/theme/schemes/createTheme.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { themeDefaults } from './base'; -import { tokens } from '../tokens'; -import { buildScheme } from '../tokens/sys/color'; -import type { ContrastLevel, Theme } from '../types'; - -export type CreateThemeOptions = { - dark?: boolean; - contrast?: ContrastLevel; -}; - -/** - * Builds a theme for a given color scheme and contrast level. - * - * Prefer the `contrast` prop on `PaperProvider` over calling this directly, - * because passing a `theme` object turns off automatic system dark mode. - */ -export const createTheme = ({ - dark = false, - contrast = 'standard', -}: CreateThemeOptions = {}): Theme => ({ - ...themeDefaults, - dark, - contrast, - colors: buildScheme(tokens.md.ref.palette, { - mode: dark ? 'dark' : 'light', - contrast, - }), -}); diff --git a/src/theme/schemes/index.ts b/src/theme/schemes/index.ts index ce456bedcc..383f50004d 100644 --- a/src/theme/schemes/index.ts +++ b/src/theme/schemes/index.ts @@ -1,10 +1,19 @@ -export { LightTheme } from './LightTheme'; -export { DarkTheme } from './DarkTheme'; -export { createTheme, type CreateThemeOptions } from './createTheme'; +export { + LightTheme, + MediumContrastLightTheme, + HighContrastLightTheme, +} from './LightTheme'; +export { + DarkTheme, + MediumContrastDarkTheme, + HighContrastDarkTheme, +} from './DarkTheme'; export { DynamicLightTheme, DynamicDarkTheme, - getDynamicTheme, + MediumContrastDynamicLightTheme, + HighContrastDynamicLightTheme, + MediumContrastDynamicDarkTheme, + HighContrastDynamicDarkTheme, isDynamicColorSupported, - isDynamicColorSupportedAtContrast, } from './DynamicTheme'; diff --git a/src/theme/tokens/sys/color.ts b/src/theme/tokens/sys/color.ts index ae05842227..1a9a267f8a 100644 --- a/src/theme/tokens/sys/color.ts +++ b/src/theme/tokens/sys/color.ts @@ -158,15 +158,15 @@ const roleToTone: Record< primaryFixed: 'primary90', primaryFixedDim: 'primary80', onPrimaryFixed: 'primary10', - onPrimaryFixedVariant: 'primary30', + onPrimaryFixedVariant: 'primary20', secondaryFixed: 'secondary90', secondaryFixedDim: 'secondary80', onSecondaryFixed: 'secondary10', - onSecondaryFixedVariant: 'secondary30', + onSecondaryFixedVariant: 'secondary20', tertiaryFixed: 'tertiary90', tertiaryFixedDim: 'tertiary80', onTertiaryFixed: 'tertiary10', - onTertiaryFixedVariant: 'tertiary30', + onTertiaryFixedVariant: 'tertiary20', shadow: 'neutral0', scrim: 'neutral0', }, @@ -310,15 +310,15 @@ const roleToTone: Record< primaryFixed: 'primary90', primaryFixedDim: 'primary80', onPrimaryFixed: 'primary10', - onPrimaryFixedVariant: 'primary30', + onPrimaryFixedVariant: 'primary20', secondaryFixed: 'secondary90', secondaryFixedDim: 'secondary80', onSecondaryFixed: 'secondary10', - onSecondaryFixedVariant: 'secondary30', + onSecondaryFixedVariant: 'secondary20', tertiaryFixed: 'tertiary90', tertiaryFixedDim: 'tertiary80', onTertiaryFixed: 'tertiary10', - onTertiaryFixedVariant: 'tertiary30', + onTertiaryFixedVariant: 'tertiary20', shadow: 'neutral0', scrim: 'neutral0', }, diff --git a/src/theme/types/theme.ts b/src/theme/types/theme.ts index 9dd54d6cf7..109d7575e1 100644 --- a/src/theme/types/theme.ts +++ b/src/theme/types/theme.ts @@ -10,7 +10,6 @@ export type ContrastLevel = 'standard' | 'medium' | 'high'; export type Theme = { dark: boolean; - contrast: ContrastLevel; animation: { scale: number; };