@rific/auto-paper 0.13.2 → 0.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -413,7 +413,7 @@ function SettingsScreen() {
413
413
 
414
414
  ### `Appbar`
415
415
 
416
- A thin wrapper around `react-native-paper`'s `Appbar` that automatically syncs `StatusBar` background color and bar style to the current theme surface color (no `StatusBar` under `<Provider statusBar={false}>`).
416
+ A thin wrapper around `react-native-paper`'s `Appbar` that automatically syncs the `StatusBar` bar style to the current theme (React Native 0.88 removed `StatusBar`'s Android-only `backgroundColor`, which edge-to-edge already ignored, so 0.13.3 stopped passing it) (no `StatusBar` under `<Provider statusBar={false}>`).
417
417
 
418
418
  ```tsx
419
419
  import { Appbar } from '@rific/auto-paper'
@@ -648,7 +648,7 @@ const appearance = useSelector((state: RootState) => selectThemeAppearance(state
648
648
  ### Color utilities
649
649
 
650
650
  ```ts
651
- import { getTriadicPalette, getThirdColor, getBlendedColor, getColorRoles, getContrastColor, isDarkColor, getRgb, getHex, getTonalColor, getTintTextColor, resolveSeedColor } from '@rific/auto-paper'
651
+ import { getTriadicPalette, getThirdColor, getBlendedColor, getColorRoles, getContrastColor, getContrastRatio, getLuminance, isDarkColor, getRgb, getHex, getTonalColor, getTintTextColor, resolveSeedColor } from '@rific/auto-paper'
652
652
  import type { ColorHarmony, TriadicPalette } from '@rific/auto-paper'
653
653
 
654
654
  getTriadicPalette('#6750a4') // split-complementary (default)
@@ -661,7 +661,9 @@ getTriadicPalette('#6750a4', 'double-split')
661
661
  getThirdColor('#e53935', '#1e88e5') // → '#59e52a' (hue maximally distant from both inputs; s/l averaged from both)
662
662
 
663
663
  getBlendedColor('#ff0000', '#0000ff', 0.5) // → '#800080'
664
- getColorRoles('#6750a4', '#fffbfe') // → { color, onColor, container, onContainer }: the MD3 color/onColor/container/onContainer math, generalized (this is what useComputedTheme uses internally for every role)
664
+ getColorRoles('#6750a4', '#fffbfe') // → { color, onColor, container, onContainer }: the MD3 color/onColor/container/onContainer math, generalized (this is what useComputedTheme uses internally for every role). onColor is light while light text reads on the color at 2.5:1, dark below that
665
+ getContrastRatio('#767676', '#ffffff') // → 4.54 (WCAG 2 contrast ratio, 1 to 21, either order)
666
+ getLuminance('#ffffff') // → 1 (WCAG 2 relative luminance, 0 for black)
665
667
  getContrastColor('#6750a4') // → '#ffffff' (black/white pick for content sitting on a fully saturated fill)
666
668
  isDarkColor('#6750a4') // → true
667
669
  getRgb('coral') // → { r: 255, g: 127, b: 80 }
package/dist/index.d.mts CHANGED
@@ -391,6 +391,9 @@ type ColorRoles = {
391
391
  };
392
392
  declare const getColorRoles: (base: string, surface: string, containerAlpha?: number) => ColorRoles;
393
393
 
394
+ declare const getLuminance: (value: string) => number;
395
+ declare const getContrastRatio: (a: string, b: string) => number;
396
+
394
397
  declare const getHex: (value: string) => string | null;
395
398
 
396
399
  type RGB = {
@@ -413,4 +416,4 @@ declare const isDarkColor: (value: string) => boolean;
413
416
 
414
417
  declare const resolveSeedColor: (color: string | TriadicPalette) => string;
415
418
 
416
- export { Appbar, type AppearanceIcons, AppearancePicker, AutoAppearancePicker, AutoPalettePicker, type AutoPaperTheme, BlurView, type BlurViewProps, BottomNavigation, Button, Chip, type ChipProps, type ColorHarmony, ColorPicker, type ColorRoles, Dialog, type DialogProps, type ExpoBlurModule, type ExpoNavigationBarModule, FAB, FadingScrollView, type FadingScrollViewProps, HarmonyPicker, IconButton, type IconButtonProps, type MaskedViewModule, Menu, type MenuProps, PalettePicker, type PaletteWeights, type PaperDefaults, Provider, type ProviderProps, type RGB, type ReanimatedModule, SCROLL_FADE_HEIGHT, SEMANTIC_BASE_COLORS, type SeedColor, type SemanticColorRoles, TextInput, type ThemeAppearance, type ThemeBridgeProps, type ThemeSettings, ThemeSettingsContext, type ThemeSettingsContextType, type ThemeState, type TriadicPalette, createThemeReducer, defaultColors, defaultThemeSettings, getBlendedColor, getColorRoles, getContrastColor, getHex, getRgb, getThirdColor, getTintTextColor, getTonalColor, getTriadicPalette, isDarkColor, resolveSeedColor, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer, useAutoPaperTheme, useBlur, useBlurModule, useComputedTheme, useMaskedViewModule, useNavBarContext, usePaperDefaults, useReanimatedModule, useThemeBridgeProps, useThemeSettings };
419
+ export { Appbar, type AppearanceIcons, AppearancePicker, AutoAppearancePicker, AutoPalettePicker, type AutoPaperTheme, BlurView, type BlurViewProps, BottomNavigation, Button, Chip, type ChipProps, type ColorHarmony, ColorPicker, type ColorRoles, Dialog, type DialogProps, type ExpoBlurModule, type ExpoNavigationBarModule, FAB, FadingScrollView, type FadingScrollViewProps, HarmonyPicker, IconButton, type IconButtonProps, type MaskedViewModule, Menu, type MenuProps, PalettePicker, type PaletteWeights, type PaperDefaults, Provider, type ProviderProps, type RGB, type ReanimatedModule, SCROLL_FADE_HEIGHT, SEMANTIC_BASE_COLORS, type SeedColor, type SemanticColorRoles, TextInput, type ThemeAppearance, type ThemeBridgeProps, type ThemeSettings, ThemeSettingsContext, type ThemeSettingsContextType, type ThemeState, type TriadicPalette, createThemeReducer, defaultColors, defaultThemeSettings, getBlendedColor, getColorRoles, getContrastColor, getContrastRatio, getHex, getLuminance, getRgb, getThirdColor, getTintTextColor, getTonalColor, getTriadicPalette, isDarkColor, resolveSeedColor, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer, useAutoPaperTheme, useBlur, useBlurModule, useComputedTheme, useMaskedViewModule, useNavBarContext, usePaperDefaults, useReanimatedModule, useThemeBridgeProps, useThemeSettings };
package/dist/index.d.ts CHANGED
@@ -391,6 +391,9 @@ type ColorRoles = {
391
391
  };
392
392
  declare const getColorRoles: (base: string, surface: string, containerAlpha?: number) => ColorRoles;
393
393
 
394
+ declare const getLuminance: (value: string) => number;
395
+ declare const getContrastRatio: (a: string, b: string) => number;
396
+
394
397
  declare const getHex: (value: string) => string | null;
395
398
 
396
399
  type RGB = {
@@ -413,4 +416,4 @@ declare const isDarkColor: (value: string) => boolean;
413
416
 
414
417
  declare const resolveSeedColor: (color: string | TriadicPalette) => string;
415
418
 
416
- export { Appbar, type AppearanceIcons, AppearancePicker, AutoAppearancePicker, AutoPalettePicker, type AutoPaperTheme, BlurView, type BlurViewProps, BottomNavigation, Button, Chip, type ChipProps, type ColorHarmony, ColorPicker, type ColorRoles, Dialog, type DialogProps, type ExpoBlurModule, type ExpoNavigationBarModule, FAB, FadingScrollView, type FadingScrollViewProps, HarmonyPicker, IconButton, type IconButtonProps, type MaskedViewModule, Menu, type MenuProps, PalettePicker, type PaletteWeights, type PaperDefaults, Provider, type ProviderProps, type RGB, type ReanimatedModule, SCROLL_FADE_HEIGHT, SEMANTIC_BASE_COLORS, type SeedColor, type SemanticColorRoles, TextInput, type ThemeAppearance, type ThemeBridgeProps, type ThemeSettings, ThemeSettingsContext, type ThemeSettingsContextType, type ThemeState, type TriadicPalette, createThemeReducer, defaultColors, defaultThemeSettings, getBlendedColor, getColorRoles, getContrastColor, getHex, getRgb, getThirdColor, getTintTextColor, getTonalColor, getTriadicPalette, isDarkColor, resolveSeedColor, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer, useAutoPaperTheme, useBlur, useBlurModule, useComputedTheme, useMaskedViewModule, useNavBarContext, usePaperDefaults, useReanimatedModule, useThemeBridgeProps, useThemeSettings };
419
+ export { Appbar, type AppearanceIcons, AppearancePicker, AutoAppearancePicker, AutoPalettePicker, type AutoPaperTheme, BlurView, type BlurViewProps, BottomNavigation, Button, Chip, type ChipProps, type ColorHarmony, ColorPicker, type ColorRoles, Dialog, type DialogProps, type ExpoBlurModule, type ExpoNavigationBarModule, FAB, FadingScrollView, type FadingScrollViewProps, HarmonyPicker, IconButton, type IconButtonProps, type MaskedViewModule, Menu, type MenuProps, PalettePicker, type PaletteWeights, type PaperDefaults, Provider, type ProviderProps, type RGB, type ReanimatedModule, SCROLL_FADE_HEIGHT, SEMANTIC_BASE_COLORS, type SeedColor, type SemanticColorRoles, TextInput, type ThemeAppearance, type ThemeBridgeProps, type ThemeSettings, ThemeSettingsContext, type ThemeSettingsContextType, type ThemeState, type TriadicPalette, createThemeReducer, defaultColors, defaultThemeSettings, getBlendedColor, getColorRoles, getContrastColor, getContrastRatio, getHex, getLuminance, getRgb, getThirdColor, getTintTextColor, getTonalColor, getTriadicPalette, isDarkColor, resolveSeedColor, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer, useAutoPaperTheme, useBlur, useBlurModule, useComputedTheme, useMaskedViewModule, useNavBarContext, usePaperDefaults, useReanimatedModule, useThemeBridgeProps, useThemeSettings };
package/dist/index.js CHANGED
@@ -57,7 +57,9 @@ __export(index_exports, {
57
57
  getBlendedColor: () => getBlendedColor,
58
58
  getColorRoles: () => getColorRoles,
59
59
  getContrastColor: () => getContrastColor,
60
+ getContrastRatio: () => getContrastRatio,
60
61
  getHex: () => getHex,
62
+ getLuminance: () => getLuminance,
61
63
  getRgb: () => getRgb,
62
64
  getThirdColor: () => getThirdColor,
63
65
  getTintTextColor: () => getTintTextColor,
@@ -353,6 +355,21 @@ var getBlendedColor = (foreground, background, alpha) => {
353
355
  return getHex(`rgb(${r}, ${g}, ${b})`) ?? background;
354
356
  };
355
357
 
358
+ // src/utils/getContrastRatio.ts
359
+ var getLuminance = (value) => {
360
+ const rgb = getRgb(value);
361
+ if (!rgb) return 0;
362
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((channel) => {
363
+ const c = channel / 255;
364
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
365
+ });
366
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
367
+ };
368
+ var getContrastRatio = (a, b) => {
369
+ const [lighter, darker] = [getLuminance(a), getLuminance(b)].sort((x, y) => y - x);
370
+ return (lighter + 0.05) / (darker + 0.05);
371
+ };
372
+
356
373
  // src/utils/getTonalColor.ts
357
374
  var getTonalColor = (color, lightness) => {
358
375
  const rgb = getRgb(color);
@@ -417,11 +434,13 @@ var isDarkColor = (value) => {
417
434
  };
418
435
 
419
436
  // src/utils/getColorRoles.ts
437
+ var MIN_LIGHT_ON_COLOR_CONTRAST = 2.5;
420
438
  var getColorRoles = (base, surface, containerAlpha = 0.15) => {
421
439
  const container = getBlendedColor(base, surface, containerAlpha);
440
+ const lightOnColor = getTonalColor(base, 0.95);
422
441
  return {
423
442
  color: base,
424
- onColor: getTonalColor(base, isDarkColor(base) ? 0.95 : 0.08),
443
+ onColor: getContrastRatio(lightOnColor, base) >= MIN_LIGHT_ON_COLOR_CONTRAST ? lightOnColor : getTonalColor(base, 0.08),
425
444
  container,
426
445
  onContainer: getTonalColor(base, isDarkColor(container) ? 0.92 : 0.12)
427
446
  };
@@ -628,7 +647,7 @@ function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, maskedVie
628
647
  }, [theme, onReady]);
629
648
  if (!theme) return null;
630
649
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native_paper2.Provider, { theme, children: [
631
- statusBar && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.StatusBar, { backgroundColor: theme.colors.background, barStyle: theme.dark ? "light-content" : "dark-content", ...statusBarProps }),
650
+ statusBar && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.StatusBar, { barStyle: theme.dark ? "light-content" : "dark-content", ...statusBarProps }),
632
651
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusBarEnabledContext.Provider, { value: statusBar, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PaperDefaultsContext.Provider, { value: defaults ?? {}, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NavBarContext.Provider, { value: { navigationBar, onNavBarChange }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(BlurModuleContext.Provider, { value: expoBlur, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ReanimatedModuleContext.Provider, { value: reanimated, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MaskedViewModuleContext.Provider, { value: maskedView, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native_paper2.Portal.Host, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { style: [styles.flex, { backgroundColor: theme.colors.background }, style], children }) }) }) }) }) }) }) })
633
652
  ] });
634
653
  }
@@ -643,7 +662,7 @@ function Header({ style, ...props }) {
643
662
  const theme = (0, import_react_native_paper3.useTheme)();
644
663
  const statusBar = useStatusBarEnabled();
645
664
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
646
- statusBar && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native3.StatusBar, { backgroundColor: theme.colors.surface, barStyle: theme.dark ? "light-content" : "dark-content" }),
665
+ statusBar && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native3.StatusBar, { barStyle: theme.dark ? "light-content" : "dark-content" }),
647
666
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper3.Appbar.Header, { ...defaults.AppbarHeader, ...props, style })
648
667
  ] });
649
668
  }
@@ -1325,7 +1344,9 @@ var getTintTextColor = (tintColor, backdropColor, tintOpacity) => {
1325
1344
  getBlendedColor,
1326
1345
  getColorRoles,
1327
1346
  getContrastColor,
1347
+ getContrastRatio,
1328
1348
  getHex,
1349
+ getLuminance,
1329
1350
  getRgb,
1330
1351
  getThirdColor,
1331
1352
  getTintTextColor,
package/dist/index.mjs CHANGED
@@ -267,6 +267,21 @@ var getBlendedColor = (foreground, background, alpha) => {
267
267
  return getHex(`rgb(${r}, ${g}, ${b})`) ?? background;
268
268
  };
269
269
 
270
+ // src/utils/getContrastRatio.ts
271
+ var getLuminance = (value) => {
272
+ const rgb = getRgb(value);
273
+ if (!rgb) return 0;
274
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((channel) => {
275
+ const c = channel / 255;
276
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
277
+ });
278
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
279
+ };
280
+ var getContrastRatio = (a, b) => {
281
+ const [lighter, darker] = [getLuminance(a), getLuminance(b)].sort((x, y) => y - x);
282
+ return (lighter + 0.05) / (darker + 0.05);
283
+ };
284
+
270
285
  // src/utils/getTonalColor.ts
271
286
  var getTonalColor = (color, lightness) => {
272
287
  const rgb = getRgb(color);
@@ -331,11 +346,13 @@ var isDarkColor = (value) => {
331
346
  };
332
347
 
333
348
  // src/utils/getColorRoles.ts
349
+ var MIN_LIGHT_ON_COLOR_CONTRAST = 2.5;
334
350
  var getColorRoles = (base, surface, containerAlpha = 0.15) => {
335
351
  const container = getBlendedColor(base, surface, containerAlpha);
352
+ const lightOnColor = getTonalColor(base, 0.95);
336
353
  return {
337
354
  color: base,
338
- onColor: getTonalColor(base, isDarkColor(base) ? 0.95 : 0.08),
355
+ onColor: getContrastRatio(lightOnColor, base) >= MIN_LIGHT_ON_COLOR_CONTRAST ? lightOnColor : getTonalColor(base, 0.08),
339
356
  container,
340
357
  onContainer: getTonalColor(base, isDarkColor(container) ? 0.92 : 0.12)
341
358
  };
@@ -542,7 +559,7 @@ function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, maskedVie
542
559
  }, [theme, onReady]);
543
560
  if (!theme) return null;
544
561
  return /* @__PURE__ */ jsxs(PaperProvider, { theme, children: [
545
- statusBar && /* @__PURE__ */ jsx(StatusBar, { backgroundColor: theme.colors.background, barStyle: theme.dark ? "light-content" : "dark-content", ...statusBarProps }),
562
+ statusBar && /* @__PURE__ */ jsx(StatusBar, { barStyle: theme.dark ? "light-content" : "dark-content", ...statusBarProps }),
546
563
  /* @__PURE__ */ jsx(StatusBarEnabledContext.Provider, { value: statusBar, children: /* @__PURE__ */ jsx(PaperDefaultsContext.Provider, { value: defaults ?? {}, children: /* @__PURE__ */ jsx(NavBarContext.Provider, { value: { navigationBar, onNavBarChange }, children: /* @__PURE__ */ jsx(BlurModuleContext.Provider, { value: expoBlur, children: /* @__PURE__ */ jsx(ReanimatedModuleContext.Provider, { value: reanimated, children: /* @__PURE__ */ jsx(MaskedViewModuleContext.Provider, { value: maskedView, children: /* @__PURE__ */ jsx(Portal.Host, { children: /* @__PURE__ */ jsx(View, { style: [styles.flex, { backgroundColor: theme.colors.background }, style], children }) }) }) }) }) }) }) })
547
564
  ] });
548
565
  }
@@ -557,7 +574,7 @@ function Header({ style, ...props }) {
557
574
  const theme = useTheme();
558
575
  const statusBar = useStatusBarEnabled();
559
576
  return /* @__PURE__ */ jsxs2(Fragment, { children: [
560
- statusBar && /* @__PURE__ */ jsx2(StatusBar2, { backgroundColor: theme.colors.surface, barStyle: theme.dark ? "light-content" : "dark-content" }),
577
+ statusBar && /* @__PURE__ */ jsx2(StatusBar2, { barStyle: theme.dark ? "light-content" : "dark-content" }),
561
578
  /* @__PURE__ */ jsx2(PaperAppbar.Header, { ...defaults.AppbarHeader, ...props, style })
562
579
  ] });
563
580
  }
@@ -1238,7 +1255,9 @@ export {
1238
1255
  getBlendedColor,
1239
1256
  getColorRoles,
1240
1257
  getContrastColor,
1258
+ getContrastRatio,
1241
1259
  getHex,
1260
+ getLuminance,
1242
1261
  getRgb,
1243
1262
  getThirdColor,
1244
1263
  getTintTextColor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rific/auto-paper",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "description": "Adaptive react-native-paper theming: triadic palette from a seed color + system/light/dark appearance",
5
5
  "keywords": [
6
6
  "react-native",
@@ -69,8 +69,8 @@
69
69
  },
70
70
  "devDependencies": {
71
71
  "@infinitetoken/eslint-config": "0.2.0",
72
- "@infinitetoken/jest-config": "^0.4.0",
73
- "@infinitetoken/tsconfig": "^0.5.0",
72
+ "@infinitetoken/jest-config": "^0.5.1",
73
+ "@infinitetoken/tsconfig": "^0.6.0",
74
74
  "@react-native-masked-view/masked-view": "^0.3.2",
75
75
  "@reduxjs/toolkit": "^2.0.0",
76
76
  "@rific/core": "^0.3.0",
@@ -81,16 +81,16 @@
81
81
  "eslint": "^9.39.4",
82
82
  "eslint-plugin-react-hooks": "^7.1.1",
83
83
  "eslint-plugin-react-native": "^5.0.0",
84
- "expo-blur": "^57.0.3",
85
- "expo-navigation-bar": "^57.0.3",
84
+ "expo-blur": "^58.0.3",
85
+ "expo-navigation-bar": "^58.0.4",
86
86
  "jest": "^30.0.0",
87
87
  "jest-environment-jsdom": "^30.0.0",
88
88
  "prettier": "^3.8.3",
89
89
  "react": "^19.0.0",
90
90
  "react-dom": "^19.0.0",
91
- "react-native": "^0.86.3",
91
+ "react-native": "^0.88.0",
92
92
  "react-native-paper": "^5.12.0",
93
- "react-native-safe-area-context": "^5.5.2",
93
+ "react-native-safe-area-context": "^5.10.1",
94
94
  "tsup": "^8.0.0",
95
95
  "typescript": "^6.0.3"
96
96
  },
@@ -109,7 +109,7 @@ function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, maskedVie
109
109
 
110
110
  return (
111
111
  <PaperProvider theme={theme}>
112
- {statusBar && <StatusBar backgroundColor={theme.colors.background} barStyle={theme.dark ? 'light-content' : 'dark-content'} {...statusBarProps} />}
112
+ {statusBar && <StatusBar barStyle={theme.dark ? 'light-content' : 'dark-content'} {...statusBarProps} />}
113
113
  <StatusBarEnabledContext.Provider value={statusBar}>
114
114
  <PaperDefaultsContext.Provider value={defaults ?? {}}>
115
115
  <NavBarContext.Provider value={{ navigationBar, onNavBarChange }}>
@@ -11,7 +11,7 @@ function Header({ style, ...props }: AppbarHeaderProps) {
11
11
  const statusBar = useStatusBarEnabled()
12
12
  return (
13
13
  <>
14
- {statusBar && <StatusBar backgroundColor={theme.colors.surface} barStyle={theme.dark ? 'light-content' : 'dark-content'} />}
14
+ {statusBar && <StatusBar barStyle={theme.dark ? 'light-content' : 'dark-content'} />}
15
15
  <PaperAppbar.Header {...defaults.AppbarHeader} {...props} style={style} />
16
16
  </>
17
17
  )
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export { useThemeSettings } from './useThemeSettings'
43
43
  export { getBlendedColor } from './utils/getBlendedColor'
44
44
  export type { ColorRoles } from './utils/getColorRoles'
45
45
  export { getColorRoles } from './utils/getColorRoles'
46
+ export { getContrastRatio, getLuminance } from './utils/getContrastRatio'
46
47
  export { getHex } from './utils/getHex'
47
48
  export type { RGB } from './utils/getRgb'
48
49
  export { getRgb } from './utils/getRgb'
@@ -1,4 +1,5 @@
1
1
  import { getBlendedColor } from './getBlendedColor'
2
+ import { getContrastRatio } from './getContrastRatio'
2
3
  import { getTonalColor } from './getTonalColor'
3
4
  import { isDarkColor } from './isDarkColor'
4
5
 
@@ -9,18 +10,28 @@ export type ColorRoles = {
9
10
  onContainer: string
10
11
  }
11
12
 
13
+ // The lowest contrast light text may have on a color before onColor turns dark instead. The fleet's
14
+ // colored buttons have always had light text, and keep it wherever it reads about as well as it does on
15
+ // their reds, blues and greens (2.8 to 3.1:1 for the picker's Red, Blue, Emerald and Coral); only
16
+ // colors light enough that it fades take dark text: the picker's Lime, Amber, Cyan and Sky (Yellow
17
+ // already did). Judging by the color's own darkness alone gave those near-white text that all but
18
+ // vanished (Jay, 2026-10-19: 1.96:1 on a light green Ready button).
19
+ const MIN_LIGHT_ON_COLOR_CONTRAST = 2.5
20
+
12
21
  // The MD3 "color / onColor / container / onContainer" quadruple, generalized from the math
13
22
  // useComputedTheme already uses for primary/secondary/tertiary: onColor is tuned to sit on the
14
- // vivid color itself, container is base blended toward the theme's real surface (so it inherits
23
+ // vivid color itself (light while it reads, MIN_LIGHT_ON_COLOR_CONTRAST, else dark), container is
24
+ // base blended toward the theme's real surface (so it inherits
15
25
  // that surface's actual lightness rather than a fixed light/dark guess), and onContainer is tuned
16
26
  // against the *computed* container rather than a light/dark assumption, so it stays readable
17
27
  // whatever surface color it's handed. onContainer also reads safely directly on `surface` itself
18
28
  // (not just on `container`), since container is only lightly blended toward it.
19
29
  export const getColorRoles = (base: string, surface: string, containerAlpha = 0.15): ColorRoles => {
20
30
  const container = getBlendedColor(base, surface, containerAlpha)
31
+ const lightOnColor = getTonalColor(base, 0.95)
21
32
  return {
22
33
  color: base,
23
- onColor: getTonalColor(base, isDarkColor(base) ? 0.95 : 0.08),
34
+ onColor: getContrastRatio(lightOnColor, base) >= MIN_LIGHT_ON_COLOR_CONTRAST ? lightOnColor : getTonalColor(base, 0.08),
24
35
  container,
25
36
  onContainer: getTonalColor(base, isDarkColor(container) ? 0.92 : 0.12)
26
37
  }
@@ -0,0 +1,20 @@
1
+ import { getRgb } from './getRgb'
2
+
3
+ // WCAG 2's relative luminance (0 for black, 1 for white): the same formula isDarkColor applies.
4
+ export const getLuminance = (value: string): number => {
5
+ const rgb = getRgb(value)
6
+ if (!rgb) return 0
7
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((channel) => {
8
+ const c = channel / 255
9
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
10
+ })
11
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
12
+ }
13
+
14
+ // WCAG 2's contrast ratio between two colors, 1 (identical) to 21 (black on white). Order doesn't matter.
15
+ export const getContrastRatio = (a: string, b: string): number => {
16
+ const [lighter, darker] = [getLuminance(a), getLuminance(b)].sort((x, y) => y - x)
17
+ return (lighter + 0.05) / (darker + 0.05)
18
+ }
19
+
20
+ export default getContrastRatio