diff --git a/docs/6.x/docs/guides/theming.mdx b/docs/6.x/docs/guides/theming.mdx index 58836dc167..bb9ab156c5 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 6db11f9fef..af16cdb4a7 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 dcc0fa4cac..2254fd30f3 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" @@ -18202,6 +18209,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"