@rific/auto-paper 0.13.3 → 0.14.0

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/dist/index.mjs CHANGED
@@ -244,13 +244,13 @@ var getRgb = (value) => {
244
244
  var getHex = (value) => {
245
245
  const rgb = getRgb(value);
246
246
  if (!rgb) return null;
247
- const toHex = (n) => {
247
+ const toHex2 = (n) => {
248
248
  const hex = Math.round(n).toString(16);
249
249
  return hex.length === 1 ? "0" + hex : hex;
250
250
  };
251
- const hexString = `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
251
+ const hexString = `#${toHex2(rgb.r)}${toHex2(rgb.g)}${toHex2(rgb.b)}`;
252
252
  if (rgb.a !== void 0) {
253
- return `${hexString}${toHex(rgb.a * 255)}`;
253
+ return `${hexString}${toHex2(rgb.a * 255)}`;
254
254
  }
255
255
  return hexString;
256
256
  };
@@ -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
  };
@@ -415,11 +432,11 @@ function hslToHex(h, s, l) {
415
432
  g = 0;
416
433
  b = x;
417
434
  }
418
- const toHex = (value) => {
435
+ const toHex2 = (value) => {
419
436
  const hex = Math.round((value + m) * 255).toString(16);
420
437
  return hex.length === 1 ? `0${hex}` : hex;
421
438
  };
422
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
439
+ return `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`;
423
440
  }
424
441
 
425
442
  // src/useComputedTheme.ts
@@ -432,7 +449,7 @@ var SEMANTIC_BASE_COLORS = {
432
449
  warning: "#A15C00",
433
450
  danger: "#B00020"
434
451
  };
435
- function useComputedTheme(appearance, color, harmony = "split-complementary") {
452
+ function useComputedTheme(appearance, color, harmony = "split-complementary", paletteTone) {
436
453
  const [theme, setTheme] = useState(null);
437
454
  const primaryInput = typeof color === "string" ? color : color.primary;
438
455
  const secondaryInput = typeof color === "string" ? null : color.secondary;
@@ -465,7 +482,8 @@ function useComputedTheme(appearance, color, harmony = "split-complementary") {
465
482
  onDangerContainer: dangerRoles.onContainer
466
483
  }
467
484
  };
468
- const palette = secondaryInput !== null && tertiaryInput !== null ? { primary: normalizeExplicitColor(primaryInput), secondary: normalizeExplicitColor(secondaryInput), tertiary: normalizeExplicitColor(tertiaryInput) } : getTriadicPalette(primaryInput, harmony);
485
+ const untoned = secondaryInput !== null && tertiaryInput !== null ? { primary: normalizeExplicitColor(primaryInput), secondary: normalizeExplicitColor(secondaryInput), tertiary: normalizeExplicitColor(tertiaryInput) } : getTriadicPalette(primaryInput, harmony);
486
+ const palette = paletteTone ? { primary: paletteTone(untoned.primary, base.dark), secondary: paletteTone(untoned.secondary, base.dark), tertiary: paletteTone(untoned.tertiary, base.dark) } : untoned;
469
487
  const primaryRoles = getColorRoles(palette.primary, surface);
470
488
  _theme.colors.primary = primaryRoles.color;
471
489
  _theme.colors.onPrimary = primaryRoles.onColor;
@@ -498,7 +516,7 @@ function useComputedTheme(appearance, color, harmony = "split-complementary") {
498
516
  level5: getBlendedColor(blendedGrey, _theme.colors.surface, 0.7)
499
517
  };
500
518
  setTheme(_theme);
501
- }, [appearance, primaryInput, secondaryInput, tertiaryInput, harmony]);
519
+ }, [appearance, primaryInput, secondaryInput, tertiaryInput, harmony, paletteTone]);
502
520
  useEffect(() => {
503
521
  computeTheme();
504
522
  }, [computeTheme]);
@@ -519,15 +537,17 @@ var ReanimatedModuleContext = createContext2(void 0);
519
537
  var useReanimatedModule = () => useContext3(ReanimatedModuleContext);
520
538
  var MaskedViewModuleContext = createContext2(void 0);
521
539
  var useMaskedViewModule = () => useContext3(MaskedViewModuleContext);
540
+ var PaletteToneContext = createContext2(void 0);
541
+ var usePaletteTone = () => useContext3(PaletteToneContext);
522
542
  var StatusBarEnabledContext = createContext2(true);
523
543
  var useStatusBarEnabled = () => useContext3(StatusBarEnabledContext);
524
- function Provider({ children, defaults, expoBlur, fontFamily, initialValue, maskedView, navigationBar, onChange, onNavBarChange, onReady, reanimated, statusBar, statusBarProps, style }) {
525
- return /* @__PURE__ */ jsx(ThemeSettingsProvider, { initialValue, onChange, children: /* @__PURE__ */ jsx(ThemeProviderBody, { defaults, expoBlur, fontFamily, maskedView, navigationBar, onNavBarChange, onReady, reanimated, statusBar, statusBarProps, style, children }) });
544
+ function Provider({ children, defaults, expoBlur, fontFamily, initialValue, maskedView, navigationBar, onChange, onNavBarChange, onReady, paletteTone, reanimated, statusBar, statusBarProps, style }) {
545
+ return /* @__PURE__ */ jsx(ThemeSettingsProvider, { initialValue, onChange, children: /* @__PURE__ */ jsx(ThemeProviderBody, { defaults, expoBlur, fontFamily, maskedView, navigationBar, onNavBarChange, onReady, paletteTone, reanimated, statusBar, statusBarProps, style, children }) });
526
546
  }
527
547
  var ThemeSettingsProvider = themeSettingsProvider;
528
- function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, maskedView, navigationBar, onNavBarChange, onReady, reanimated, statusBar = true, statusBarProps, style }) {
548
+ function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, maskedView, navigationBar, onNavBarChange, onReady, paletteTone, reanimated, statusBar = true, statusBarProps, style }) {
529
549
  const { settings } = useThemeSettingsInternal();
530
- const computedTheme = useComputedTheme(settings.appearance, settings.color, settings.harmony);
550
+ const computedTheme = useComputedTheme(settings.appearance, settings.color, settings.harmony, paletteTone);
531
551
  const theme = useMemo(() => {
532
552
  if (!computedTheme) return null;
533
553
  if (!fontFamily) return computedTheme;
@@ -543,7 +563,7 @@ function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, maskedVie
543
563
  if (!theme) return null;
544
564
  return /* @__PURE__ */ jsxs(PaperProvider, { theme, children: [
545
565
  statusBar && /* @__PURE__ */ jsx(StatusBar, { barStyle: theme.dark ? "light-content" : "dark-content", ...statusBarProps }),
546
- /* @__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 }) }) }) }) }) }) }) })
566
+ /* @__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(PaletteToneContext.Provider, { value: paletteTone, children: /* @__PURE__ */ jsx(Portal.Host, { children: /* @__PURE__ */ jsx(View, { style: [styles.flex, { backgroundColor: theme.colors.background }, style], children }) }) }) }) }) }) }) }) })
547
567
  ] });
548
568
  }
549
569
  var styles = StyleSheet.create({
@@ -596,11 +616,15 @@ var AutoAppearancePicker = (props) => {
596
616
  return /* @__PURE__ */ jsx4(AppearancePicker, { ...props, value: settings.appearance, onChange: (appearance) => set({ appearance }) });
597
617
  };
598
618
 
619
+ // src/components/AutoPalettePicker.tsx
620
+ import { useCallback as useCallback2 } from "react";
621
+ import { useTheme as useTheme6 } from "react-native-paper";
622
+
599
623
  // src/utils/resolveSeedColor.ts
600
624
  var resolveSeedColor = (color) => typeof color === "string" ? color : color.primary;
601
625
 
602
626
  // src/components/PalettePicker.tsx
603
- import { useState as useState5 } from "react";
627
+ import { useMemo as useMemo2, useState as useState5 } from "react";
604
628
  import { StyleSheet as StyleSheet5, TouchableOpacity as TouchableOpacity2, View as View5 } from "react-native";
605
629
  import { Icon as Icon2, TouchableRipple as TouchableRipple2, useTheme as useTheme5 } from "react-native-paper";
606
630
 
@@ -846,19 +870,25 @@ var buildWedges = (weights) => {
846
870
  ];
847
871
  };
848
872
  var Pie = ({ palette, weights = defaultWeights, size = SIZE2 }) => /* @__PURE__ */ jsx9(View5, { style: [styles4.pie, { borderRadius: size / 2, height: size, width: size }], children: buildWedges(weights).map(({ key, rotate, share }) => /* @__PURE__ */ jsx9(View5, { style: [styles4.rotator, { transform: [{ rotate: `${rotate}deg` }] }], children: /* @__PURE__ */ jsx9(View5, { style: styles4.halfMask, children: /* @__PURE__ */ jsx9(View5, { style: [styles4.innerRotator, { transform: [{ rotate: `${share - 180}deg` }] }], children: /* @__PURE__ */ jsx9(View5, { style: [styles4.wedgeFill, { backgroundColor: palette[key] }] }) }) }) }, key)) });
849
- var PalettePicker = ({ value, onChange, harmony = "split-complementary", onHarmonyChange, weights, colors = defaultColors, blur: blurProp }) => {
873
+ var PalettePicker = ({ value, onChange, harmony = "split-complementary", onHarmonyChange, weights, colors = defaultColors, blur: blurProp, previewColor }) => {
850
874
  const blur = useBlur(blurProp);
851
875
  const { colors: themeColors } = useTheme5();
852
876
  const [open, setOpen] = useState5(false);
853
877
  const { onLayout, size } = useSwatchSize(SIZE2);
878
+ const shade = useMemo2(
879
+ () => (palette) => previewColor ? { primary: previewColor(palette.primary), secondary: previewColor(palette.secondary), tertiary: previewColor(palette.tertiary) } : palette,
880
+ [previewColor]
881
+ );
882
+ const triggerPalette = shade(getTriadicPalette(value, harmony));
883
+ const swatchPalettes = useMemo2(() => colors.map(({ value: hex }) => shade(getTriadicPalette(hex, harmony))), [colors, harmony, shade]);
854
884
  return /* @__PURE__ */ jsxs6(Fragment3, { children: [
855
885
  /* @__PURE__ */ jsx9(TouchableRipple2, { onPress: () => setOpen(true), style: styles4.trigger, borderless: true, children: /* @__PURE__ */ jsxs6(View5, { style: styles4.triggerContent, children: [
856
- /* @__PURE__ */ jsx9(Pie, { palette: getTriadicPalette(value, harmony), weights }),
857
- /* @__PURE__ */ jsx9(View5, { pointerEvents: "none", style: [styles4.triggerIcon, { borderRadius: SIZE2 / 2 }, getSwatchRing(value, false, themeColors.outlineVariant)], children: /* @__PURE__ */ jsx9(Icon2, { source: "palette", size: 20, color: getContrastColor(value) }) })
886
+ /* @__PURE__ */ jsx9(Pie, { palette: triggerPalette, weights }),
887
+ /* @__PURE__ */ jsx9(View5, { pointerEvents: "none", style: [styles4.triggerIcon, { borderRadius: SIZE2 / 2 }, getSwatchRing(triggerPalette.primary, false, themeColors.outlineVariant)], children: /* @__PURE__ */ jsx9(Icon2, { source: "palette", size: 20, color: getContrastColor(triggerPalette.primary) }) })
858
888
  ] }) }),
859
889
  /* @__PURE__ */ jsx9(Dialog, { blur, visible: open, onDismiss: () => setOpen(false), children: /* @__PURE__ */ jsxs6(Dialog.Content, { children: [
860
890
  onHarmonyChange && /* @__PURE__ */ jsx9(View5, { style: styles4.harmonyRow, children: /* @__PURE__ */ jsx9(HarmonyPicker, { value: harmony, onChange: onHarmonyChange, showLabels: false }) }),
861
- /* @__PURE__ */ jsx9(View5, { style: styles4.swatches, onLayout, children: colors.map(({ value: hex }) => /* @__PURE__ */ jsxs6(
891
+ /* @__PURE__ */ jsx9(View5, { style: styles4.swatches, onLayout, children: colors.map(({ value: hex }, index) => /* @__PURE__ */ jsxs6(
862
892
  TouchableOpacity2,
863
893
  {
864
894
  onPress: () => {
@@ -867,8 +897,8 @@ var PalettePicker = ({ value, onChange, harmony = "split-complementary", onHarmo
867
897
  },
868
898
  style: [styles4.swatch, { height: size, width: size }],
869
899
  children: [
870
- /* @__PURE__ */ jsx9(Pie, { palette: getTriadicPalette(hex, harmony), weights, size }),
871
- /* @__PURE__ */ jsx9(View5, { pointerEvents: "none", style: [styles4.ring, { borderRadius: size / 2 }, getSwatchRing(hex, value === hex, themeColors.outlineVariant)], children: value === hex && /* @__PURE__ */ jsx9(Icon2, { source: "check", size: 20, color: getContrastColor(hex) }) })
900
+ /* @__PURE__ */ jsx9(Pie, { palette: swatchPalettes[index], weights, size }),
901
+ /* @__PURE__ */ jsx9(View5, { pointerEvents: "none", style: [styles4.ring, { borderRadius: size / 2 }, getSwatchRing(swatchPalettes[index].primary, value === hex, themeColors.outlineVariant)], children: value === hex && /* @__PURE__ */ jsx9(Icon2, { source: "check", size: 20, color: getContrastColor(swatchPalettes[index].primary) }) })
872
902
  ]
873
903
  },
874
904
  hex
@@ -895,13 +925,16 @@ var styles4 = StyleSheet5.create({
895
925
  import { jsx as jsx10 } from "react/jsx-runtime";
896
926
  var AutoPalettePicker = (props) => {
897
927
  const { settings, set } = useThemeSettings();
898
- return /* @__PURE__ */ jsx10(PalettePicker, { ...props, value: resolveSeedColor(settings.color), onChange: (color) => set({ color }), harmony: settings.harmony, onHarmonyChange: (harmony) => set({ harmony }) });
928
+ const tone = usePaletteTone();
929
+ const { dark } = useTheme6();
930
+ const toned = useCallback2((color) => tone(color, dark), [tone, dark]);
931
+ return /* @__PURE__ */ jsx10(PalettePicker, { ...props, value: resolveSeedColor(settings.color), onChange: (color) => set({ color }), harmony: settings.harmony, onHarmonyChange: (harmony) => set({ harmony }), previewColor: tone ? toned : void 0 });
899
932
  };
900
933
 
901
934
  // src/components/BottomNavigation.tsx
902
935
  import { useEffect as useEffect4 } from "react";
903
936
  import { Platform as Platform3 } from "react-native";
904
- import { BottomNavigation as PaperBottomNavigation, useTheme as useTheme6 } from "react-native-paper";
937
+ import { BottomNavigation as PaperBottomNavigation, useTheme as useTheme7 } from "react-native-paper";
905
938
 
906
939
  // src/navigation-bar.ts
907
940
  var setNavigationBarStyle = (navigationBar, dark) => {
@@ -912,7 +945,7 @@ var setNavigationBarStyle = (navigationBar, dark) => {
912
945
  // src/components/BottomNavigation.tsx
913
946
  import { jsx as jsx11 } from "react/jsx-runtime";
914
947
  function useNavigationBarSync() {
915
- const theme = useTheme6();
948
+ const theme = useTheme7();
916
949
  const { navigationBar, onNavBarChange } = useNavBarContext();
917
950
  useEffect4(() => {
918
951
  if (Platform3.OS !== "android") return;
@@ -942,11 +975,11 @@ function Button(props) {
942
975
  }
943
976
 
944
977
  // src/components/Chip.tsx
945
- import { Chip as PaperChip, useTheme as useTheme7 } from "react-native-paper";
978
+ import { Chip as PaperChip, useTheme as useTheme8 } from "react-native-paper";
946
979
  import { jsx as jsx13 } from "react/jsx-runtime";
947
980
  function Chip({ variant, style, selectedColor, ...props }) {
948
981
  const defaults = usePaperDefaults();
949
- const { colors } = useTheme7();
982
+ const { colors } = useTheme8();
950
983
  const variantStyle = variant === "primary" ? { backgroundColor: colors.primaryContainer } : variant === "secondary" ? { backgroundColor: colors.secondaryContainer } : variant === "tertiary" ? { backgroundColor: colors.tertiaryContainer } : variant === "surface" ? { backgroundColor: colors.surface } : void 0;
951
984
  const variantSelectedColor = variant === "primary" ? colors.onPrimaryContainer : variant === "secondary" ? colors.onSecondaryContainer : variant === "tertiary" ? colors.onTertiaryContainer : variant === "surface" ? colors.onSurface : void 0;
952
985
  return /* @__PURE__ */ jsx13(PaperChip, { ...defaults.Chip, selectedColor: selectedColor ?? variantSelectedColor, ...props, style: [variantStyle, style] });
@@ -961,7 +994,7 @@ function FAB(props) {
961
994
  }
962
995
 
963
996
  // src/components/FadingScrollView.tsx
964
- import { useCallback as useCallback2, useRef as useRef2, useState as useState6 } from "react";
997
+ import { useCallback as useCallback3, useRef as useRef2, useState as useState6 } from "react";
965
998
  import { Platform as Platform4, ScrollView, StyleSheet as StyleSheet6, View as View6 } from "react-native";
966
999
  import { jsx as jsx15, jsxs as jsxs7 } from "react/jsx-runtime";
967
1000
  var SCROLL_FADE_HEIGHT = 24;
@@ -969,7 +1002,7 @@ function FadingScrollView({ style, fadeHeight = SCROLL_FADE_HEIGHT, onScroll, on
969
1002
  const MaskedView = useMaskedViewModule();
970
1003
  const sizes = useRef2({ viewport: 0, content: 0, offset: 0 });
971
1004
  const [edges, setEdges] = useState6(NO_EDGES);
972
- const update = useCallback2(() => {
1005
+ const update = useCallback3(() => {
973
1006
  const { viewport, content, offset } = sizes.current;
974
1007
  const above = offset > 1;
975
1008
  const below = content - viewport - offset > 1;
@@ -1043,11 +1076,11 @@ var styles5 = StyleSheet6.create({
1043
1076
  });
1044
1077
 
1045
1078
  // src/components/IconButton.tsx
1046
- import { IconButton as PaperIconButton, useTheme as useTheme8 } from "react-native-paper";
1079
+ import { IconButton as PaperIconButton, useTheme as useTheme9 } from "react-native-paper";
1047
1080
  import { jsx as jsx16 } from "react/jsx-runtime";
1048
1081
  function IconButton({ variant, iconColor, containerColor, ...props }) {
1049
1082
  const defaults = usePaperDefaults();
1050
- const { colors } = useTheme8();
1083
+ const { colors } = useTheme9();
1051
1084
  const variantContainerColor = variant === "primary" ? colors.primaryContainer : variant === "secondary" ? colors.secondaryContainer : variant === "tertiary" ? colors.tertiaryContainer : variant === "surface" ? colors.surface : void 0;
1052
1085
  const variantIconColor = variant === "primary" ? colors.onPrimaryContainer : variant === "secondary" ? colors.onSecondaryContainer : variant === "tertiary" ? colors.onTertiaryContainer : variant === "surface" ? colors.onSurface : void 0;
1053
1086
  return /* @__PURE__ */ jsx16(PaperIconButton, { ...defaults.IconButton, containerColor: containerColor ?? variantContainerColor, iconColor: iconColor ?? variantIconColor, ...props });
@@ -1056,7 +1089,7 @@ function IconButton({ variant, iconColor, containerColor, ...props }) {
1056
1089
  // src/components/Menu.tsx
1057
1090
  import { useEffect as useEffect5, useState as useState7 } from "react";
1058
1091
  import { Animated as Animated2, Easing as Easing2, Platform as Platform5, StyleSheet as StyleSheet7 } from "react-native";
1059
- import { Menu as PaperMenu, useTheme as useTheme9 } from "react-native-paper";
1092
+ import { Menu as PaperMenu, useTheme as useTheme10 } from "react-native-paper";
1060
1093
  import { jsx as jsx17 } from "react/jsx-runtime";
1061
1094
  var ANIMATION_DURATION2 = 250;
1062
1095
  var EASING = Easing2.bezier(0.4, 0, 0.2, 1);
@@ -1065,7 +1098,7 @@ var SHADOW_HEIGHT = [0, 1, 2, 4, 6, 8];
1065
1098
  var SHADOW_RADIUS = [0, 3, 6, 8, 10, 12];
1066
1099
  var AnimatedBlurView2 = Animated2.createAnimatedComponent(BlurView);
1067
1100
  var FadingBlurView = ({ blur, children, elevation, theme: themeOverrides, visible }) => {
1068
- const { animation, roundness } = useTheme9(themeOverrides);
1101
+ const { animation, roundness } = useTheme10(themeOverrides);
1069
1102
  const [opacity] = useState7(() => new Animated2.Value(0));
1070
1103
  const [closing, setClosing] = useState7(false);
1071
1104
  if (!visible && !closing) setClosing(true);
@@ -1123,12 +1156,95 @@ import { useTheme as usePaperTheme } from "react-native-paper";
1123
1156
  var useAutoPaperTheme = () => usePaperTheme();
1124
1157
 
1125
1158
  // src/useThemeBridgeProps.ts
1126
- import { useMemo as useMemo2 } from "react";
1159
+ import { useMemo as useMemo3 } from "react";
1127
1160
  function useThemeBridgeProps(props) {
1128
1161
  const { initialValue, onChange, onReady } = props;
1129
- return useMemo2(() => ({ initialValue, onChange, onReady }), [initialValue, onChange, onReady]);
1162
+ return useMemo3(() => ({ initialValue, onChange, onReady }), [initialValue, onChange, onReady]);
1130
1163
  }
1131
1164
 
1165
+ // src/utils/getOklch.ts
1166
+ var toLinear = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
1167
+ var toGamma = (c) => c <= 31308e-7 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
1168
+ var linearToOklab = ([r, g, b]) => {
1169
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
1170
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
1171
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
1172
+ return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
1173
+ };
1174
+ var oklabToLinear = ([L, a, b]) => {
1175
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
1176
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
1177
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
1178
+ return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
1179
+ };
1180
+ var inGamut = (rgb) => rgb.every((v) => v >= -1e-6 && v <= 1 + 1e-6);
1181
+ var clamp01 = (v) => Math.min(1, Math.max(0, v));
1182
+ var toHex = (rgb) => `#${rgb.map(
1183
+ (v) => Math.round(clamp01(toGamma(clamp01(v))) * 255).toString(16).padStart(2, "0")
1184
+ ).join("")}`;
1185
+ var getOklch = (value) => {
1186
+ const rgb = getRgb(value);
1187
+ if (!rgb) return null;
1188
+ const [l, a, b] = linearToOklab([toLinear(rgb.r / 255), toLinear(rgb.g / 255), toLinear(rgb.b / 255)]);
1189
+ return { l, c: Math.hypot(a, b), h: (Math.atan2(b, a) * 180 / Math.PI + 360) % 360 };
1190
+ };
1191
+ var getOklchHex = ({ l, c, h }) => {
1192
+ const lightness = Math.min(1, Math.max(0, l));
1193
+ const radians = h * Math.PI / 180;
1194
+ const atChroma = (chroma) => oklabToLinear([lightness, chroma * Math.cos(radians), chroma * Math.sin(radians)]);
1195
+ const direct = atChroma(Math.max(0, c));
1196
+ if (inGamut(direct)) return toHex(direct);
1197
+ let low = 0;
1198
+ let high = c;
1199
+ for (let i = 0; i < 24; i++) {
1200
+ const mid = (low + high) / 2;
1201
+ if (inGamut(atChroma(mid))) low = mid;
1202
+ else high = mid;
1203
+ }
1204
+ return toHex(atChroma(low));
1205
+ };
1206
+
1207
+ // src/utils/getChalkboardColor.ts
1208
+ var MARKER = { maxLightness: 0.6, chroma: 0.85 };
1209
+ var CHALK = { lightness: 0.86, lift: 0.5, chroma: 0.8, minLuminance: 0.5 };
1210
+ var CACHE_LIMIT = 512;
1211
+ var createChalkboardTone = (options = {}) => {
1212
+ const marker = { ...MARKER, ...options.marker };
1213
+ const chalk = { ...CHALK, ...options.chalk };
1214
+ const cache = /* @__PURE__ */ new Map();
1215
+ const toMarker = (color) => {
1216
+ const oklch = getOklch(color);
1217
+ if (!oklch) return color;
1218
+ return getOklchHex({ l: Math.min(oklch.l, marker.maxLightness), c: oklch.c * marker.chroma, h: oklch.h });
1219
+ };
1220
+ const toChalk = (color) => {
1221
+ const oklch = getOklch(color);
1222
+ if (!oklch) return color;
1223
+ const c = oklch.c * chalk.chroma;
1224
+ const lifted = oklch.l < chalk.lightness ? oklch.l + (chalk.lightness - oklch.l) * chalk.lift : oklch.l;
1225
+ const result = getOklchHex({ l: lifted, c, h: oklch.h });
1226
+ if (getLuminance(result) >= chalk.minLuminance) return result;
1227
+ let low = lifted;
1228
+ let high = 1;
1229
+ for (let i = 0; i < 20; i++) {
1230
+ const mid = (low + high) / 2;
1231
+ if (getLuminance(getOklchHex({ l: mid, c, h: oklch.h })) >= chalk.minLuminance) high = mid;
1232
+ else low = mid;
1233
+ }
1234
+ return getOklchHex({ l: high, c, h: oklch.h });
1235
+ };
1236
+ return (color, dark) => {
1237
+ const key = `${dark ? "d" : "l"}${color}`;
1238
+ const cached = cache.get(key);
1239
+ if (cached !== void 0) return cached;
1240
+ const result = dark ? toChalk(color) : toMarker(color);
1241
+ if (cache.size >= CACHE_LIMIT) cache.clear();
1242
+ cache.set(key, result);
1243
+ return result;
1244
+ };
1245
+ };
1246
+ var getChalkboardColor = createChalkboardTone();
1247
+
1132
1248
  // src/utils/getThirdColor.ts
1133
1249
  function rgbToHsl(r, g, b) {
1134
1250
  r /= 255;
@@ -1232,13 +1348,19 @@ export {
1232
1348
  SEMANTIC_BASE_COLORS,
1233
1349
  TextInput,
1234
1350
  ThemeSettingsContext,
1351
+ createChalkboardTone,
1235
1352
  createThemeReducer,
1236
1353
  defaultColors,
1237
1354
  defaultThemeSettings,
1238
1355
  getBlendedColor,
1356
+ getChalkboardColor,
1239
1357
  getColorRoles,
1240
1358
  getContrastColor,
1359
+ getContrastRatio,
1241
1360
  getHex,
1361
+ getLuminance,
1362
+ getOklch,
1363
+ getOklchHex,
1242
1364
  getRgb,
1243
1365
  getThirdColor,
1244
1366
  getTintTextColor,
@@ -1258,6 +1380,7 @@ export {
1258
1380
  useComputedTheme,
1259
1381
  useMaskedViewModule,
1260
1382
  useNavBarContext,
1383
+ usePaletteTone,
1261
1384
  usePaperDefaults,
1262
1385
  useReanimatedModule,
1263
1386
  useThemeBridgeProps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rific/auto-paper",
3
- "version": "0.13.3",
3
+ "version": "0.14.0",
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,7 +69,7 @@
69
69
  },
70
70
  "devDependencies": {
71
71
  "@infinitetoken/eslint-config": "0.2.0",
72
- "@infinitetoken/jest-config": "^0.5.0",
72
+ "@infinitetoken/jest-config": "^0.5.1",
73
73
  "@infinitetoken/tsconfig": "^0.6.0",
74
74
  "@react-native-masked-view/masked-view": "^0.3.2",
75
75
  "@reduxjs/toolkit": "^2.0.0",
@@ -9,6 +9,7 @@ import type { ExpoNavigationBarModule } from './navigation-bar'
9
9
  import { type PaperDefaults, PaperDefaultsContext } from './PaperDefaultsContext'
10
10
  import { type ThemeSettings, themeSettingsProvider, useThemeSettingsInternal } from './ThemeSettingsContext'
11
11
  import { useComputedTheme } from './useComputedTheme'
12
+ import type { PaletteTone } from './utils/getChalkboardColor'
12
13
 
13
14
  // expo-navigation-bar/expo-blur are no longer auto-detected via require() - Metro doesn't
14
15
  // rewrite a require()-in-try/catch call into its module graph inside an ESM (.mjs) build, so
@@ -28,6 +29,10 @@ export const useReanimatedModule = () => useContext(ReanimatedModuleContext)
28
29
  const MaskedViewModuleContext = createContext<MaskedViewModule | undefined>(undefined)
29
30
  export const useMaskedViewModule = () => useContext(MaskedViewModuleContext)
30
31
 
32
+ // The Provider's `paletteTone`, for AutoPalettePicker to preview swatches the way the theme will draw them.
33
+ const PaletteToneContext = createContext<PaletteTone | undefined>(undefined)
34
+ export const usePaletteTone = () => useContext(PaletteToneContext)
35
+
31
36
  // Whether this package's own components (Provider, Appbar.Header) render a React Native StatusBar - see `statusBar` below.
32
37
  const StatusBarEnabledContext = createContext(true)
33
38
  export const useStatusBarEnabled = () => useContext(StatusBarEnabledContext)
@@ -51,6 +56,15 @@ export type ProviderProps = {
51
56
  onChange?: (settings: ThemeSettings) => void
52
57
  onNavBarChange?: (color: string, dark: boolean) => void
53
58
  onReady?: () => void
59
+ /** Maps each palette color (primary, secondary and tertiary, after the harmony rotation) before the
60
+ * theme derives anything from it, for an app that wants its whole palette toned, e.g.
61
+ * `getChalkboardColor` (marker in light mode, chalk in dark). The stored seed stays the player's
62
+ * raw pick, so nothing compounds on save, and `AutoPalettePicker` previews its swatches through
63
+ * the same tone. onColor/onContainer follow the toned colors by the usual light/dark split, so a
64
+ * tone decides their polarity: chalk's luminance floor is what gives its pastels dark text.
65
+ * Signal colors (success, warning, danger, MD3's error) are never toned. Must be referentially stable (a module-level
66
+ * function): the theme recomputes whenever it changes. Omit for the untoned palette. */
67
+ paletteTone?: PaletteTone
54
68
  /** Injects react-native-reanimated so `<Dialog animatedStyle={...}>` (e.g. from `useAnimatedStyle()`) can animate its card on the UI thread. Pass `import Reanimated from 'react-native-reanimated'`; omit to keep Dialog's `animatedStyle` prop a no-op. */
55
69
  reanimated?: ReanimatedModule
56
70
  /** Renders React Native's `StatusBar`, styled from the theme (here and in `Appbar.Header`). Default true. Pass false
@@ -68,10 +82,10 @@ export type ProviderProps = {
68
82
  // wrapper for the settings half; ThemeProviderBody (an actual descendant, reached via
69
83
  // useThemeSettingsInternal()) does all the theme-computation/gating work the single combined
70
84
  // component used to do directly.
71
- export function Provider({ children, defaults, expoBlur, fontFamily, initialValue, maskedView, navigationBar, onChange, onNavBarChange, onReady, reanimated, statusBar, statusBarProps, style }: ProviderProps) {
85
+ export function Provider({ children, defaults, expoBlur, fontFamily, initialValue, maskedView, navigationBar, onChange, onNavBarChange, onReady, paletteTone, reanimated, statusBar, statusBarProps, style }: ProviderProps) {
72
86
  return (
73
87
  <ThemeSettingsProvider initialValue={initialValue} onChange={onChange}>
74
- <ThemeProviderBody defaults={defaults} expoBlur={expoBlur} fontFamily={fontFamily} maskedView={maskedView} navigationBar={navigationBar} onNavBarChange={onNavBarChange} onReady={onReady} reanimated={reanimated} statusBar={statusBar} statusBarProps={statusBarProps} style={style}>
88
+ <ThemeProviderBody defaults={defaults} expoBlur={expoBlur} fontFamily={fontFamily} maskedView={maskedView} navigationBar={navigationBar} onNavBarChange={onNavBarChange} onReady={onReady} paletteTone={paletteTone} reanimated={reanimated} statusBar={statusBar} statusBarProps={statusBarProps} style={style}>
75
89
  {children}
76
90
  </ThemeProviderBody>
77
91
  </ThemeSettingsProvider>
@@ -82,10 +96,10 @@ const ThemeSettingsProvider = themeSettingsProvider
82
96
 
83
97
  type ThemeProviderBodyProps = Omit<ProviderProps, 'initialValue' | 'onChange'>
84
98
 
85
- function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, maskedView, navigationBar, onNavBarChange, onReady, reanimated, statusBar = true, statusBarProps, style }: ThemeProviderBodyProps) {
99
+ function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, maskedView, navigationBar, onNavBarChange, onReady, paletteTone, reanimated, statusBar = true, statusBarProps, style }: ThemeProviderBodyProps) {
86
100
  const { settings } = useThemeSettingsInternal()
87
101
 
88
- const computedTheme = useComputedTheme(settings.appearance, settings.color, settings.harmony)
102
+ const computedTheme = useComputedTheme(settings.appearance, settings.color, settings.harmony, paletteTone)
89
103
  // Layered on top of useComputedTheme's own output rather than folded into it - that hook only
90
104
  // ever recomputes for a color/appearance/harmony change, and fontFamily is a completely
91
105
  // independent axis; merging it there would mean either re-running the whole color computation on
@@ -116,17 +130,19 @@ function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, maskedVie
116
130
  <BlurModuleContext.Provider value={expoBlur}>
117
131
  <ReanimatedModuleContext.Provider value={reanimated}>
118
132
  <MaskedViewModuleContext.Provider value={maskedView}>
119
- {/* PaperProvider's own PortalHost sits above every context provided here, and Paper's
133
+ <PaletteToneContext.Provider value={paletteTone}>
134
+ {/* PaperProvider's own PortalHost sits above every context provided here, and Paper's
120
135
  Portal only carries its theme and settings across, so anything rendered through a
121
136
  Portal (this package's Dialog, Paper's Menu and Tooltip, or anything an app wraps
122
- in <Portal> itself) would otherwise lose expoBlur, reanimated, maskedView, defaults, statusBar and the nav
137
+ in <Portal> itself) would otherwise lose expoBlur, reanimated, maskedView, paletteTone, defaults, statusBar and the nav
123
138
  bar hooks: Dialog/Menu's blur silently fell back to solid. This nested host is the
124
139
  nearest one for every Portal under Provider, so their content mounts inside all of
125
140
  these contexts instead. It wraps the styled View, not the other way round, so
126
141
  portals still cover the full area regardless of `style`. */}
127
- <Portal.Host>
128
- <View style={[styles.flex, { backgroundColor: theme.colors.background }, style]}>{children}</View>
129
- </Portal.Host>
142
+ <Portal.Host>
143
+ <View style={[styles.flex, { backgroundColor: theme.colors.background }, style]}>{children}</View>
144
+ </Portal.Host>
145
+ </PaletteToneContext.Provider>
130
146
  </MaskedViewModuleContext.Provider>
131
147
  </ReanimatedModuleContext.Provider>
132
148
  </BlurModuleContext.Provider>
@@ -1,3 +1,7 @@
1
+ import { useCallback } from 'react'
2
+ import { useTheme } from 'react-native-paper'
3
+
4
+ import { usePaletteTone } from '../ThemeProvider'
1
5
  import { useThemeSettings } from '../useThemeSettings'
2
6
  import { resolveSeedColor } from '../utils/resolveSeedColor'
3
7
  import type { SeedColor } from './ColorPicker'
@@ -14,8 +18,13 @@ type Props = {
14
18
  // `value`/`onChange`/`harmony`/`onHarmonyChange` - reads/writes useThemeSettings() itself
15
19
  // (resolveSeedColor narrows ThemeSettings.color's `string | TriadicPalette` down to the plain
16
20
  // string PalettePicker's own `value` wants). Reach for the plain PalettePicker instead when it
17
- // needs to be controlled by something other than the global theme.
21
+ // needs to be controlled by something other than the global theme. It also previews the swatches
22
+ // through the Provider's `paletteTone`, if there is one, in the current appearance, so they match
23
+ // the theme they'd produce.
18
24
  export const AutoPalettePicker = (props: Props) => {
19
25
  const { settings, set } = useThemeSettings()
20
- return <PalettePicker {...props} value={resolveSeedColor(settings.color)} onChange={(color) => set({ color })} harmony={settings.harmony} onHarmonyChange={(harmony) => set({ harmony })} />
26
+ const tone = usePaletteTone()
27
+ const { dark } = useTheme()
28
+ const toned = useCallback((color: string) => tone!(color, dark), [tone, dark])
29
+ return <PalettePicker {...props} value={resolveSeedColor(settings.color)} onChange={(color) => set({ color })} harmony={settings.harmony} onHarmonyChange={(harmony) => set({ harmony })} previewColor={tone ? toned : undefined} />
21
30
  }
@@ -1,4 +1,4 @@
1
- import { useState } from 'react'
1
+ import { useMemo, useState } from 'react'
2
2
  import { StyleSheet, TouchableOpacity, View } from 'react-native'
3
3
  import { Icon, TouchableRipple, useTheme } from 'react-native-paper'
4
4
 
@@ -65,21 +65,35 @@ type Props = {
65
65
  weights?: PaletteWeights
66
66
  colors?: SeedColor[]
67
67
  blur?: boolean
68
+ // Opt-in: maps each wedge's color (after the harmony rotation) to what's actually drawn, so the
69
+ // swatches preview a toned theme (see Provider's `paletteTone`; AutoPalettePicker wires it from
70
+ // there). Display only: `value`, the check mark's match and onChange stay the raw seed hex.
71
+ previewColor?: (color: string) => string
68
72
  }
69
73
 
70
- export const PalettePicker = ({ value, onChange, harmony = 'split-complementary', onHarmonyChange, weights, colors = defaultColors, blur: blurProp }: Props) => {
74
+ export const PalettePicker = ({ value, onChange, harmony = 'split-complementary', onHarmonyChange, weights, colors = defaultColors, blur: blurProp, previewColor }: Props) => {
71
75
  const blur = useBlur(blurProp)
72
76
  const { colors: themeColors } = useTheme()
73
77
  const [open, setOpen] = useState(false)
74
78
  const { onLayout, size } = useSwatchSize(SIZE)
79
+ // What each pie draws. The rings and check marks contrast with the drawn primary, not the raw
80
+ // seed, since a toned swatch can be much lighter or darker than its seed.
81
+ const shade = useMemo(
82
+ () =>
83
+ (palette: TriadicPalette): TriadicPalette =>
84
+ previewColor ? { primary: previewColor(palette.primary), secondary: previewColor(palette.secondary), tertiary: previewColor(palette.tertiary) } : palette,
85
+ [previewColor]
86
+ )
87
+ const triggerPalette = shade(getTriadicPalette(value, harmony))
88
+ const swatchPalettes = useMemo(() => colors.map(({ value: hex }) => shade(getTriadicPalette(hex, harmony))), [colors, harmony, shade])
75
89
 
76
90
  return (
77
91
  <>
78
92
  <TouchableRipple onPress={() => setOpen(true)} style={styles.trigger} borderless>
79
93
  <View style={styles.triggerContent}>
80
- <Pie palette={getTriadicPalette(value, harmony)} weights={weights} />
81
- <View pointerEvents='none' style={[styles.triggerIcon, { borderRadius: SIZE / 2 }, getSwatchRing(value, false, themeColors.outlineVariant)]}>
82
- <Icon source='palette' size={20} color={getContrastColor(value)} />
94
+ <Pie palette={triggerPalette} weights={weights} />
95
+ <View pointerEvents='none' style={[styles.triggerIcon, { borderRadius: SIZE / 2 }, getSwatchRing(triggerPalette.primary, false, themeColors.outlineVariant)]}>
96
+ <Icon source='palette' size={20} color={getContrastColor(triggerPalette.primary)} />
83
97
  </View>
84
98
  </View>
85
99
  </TouchableRipple>
@@ -92,7 +106,7 @@ export const PalettePicker = ({ value, onChange, harmony = 'split-complementary'
92
106
  </View>
93
107
  )}
94
108
  <View style={styles.swatches} onLayout={onLayout}>
95
- {colors.map(({ value: hex }) => (
109
+ {colors.map(({ value: hex }, index) => (
96
110
  <TouchableOpacity
97
111
  key={hex}
98
112
  onPress={() => {
@@ -101,9 +115,9 @@ export const PalettePicker = ({ value, onChange, harmony = 'split-complementary'
101
115
  }}
102
116
  style={[styles.swatch, { height: size, width: size }]}
103
117
  >
104
- <Pie palette={getTriadicPalette(hex, harmony)} weights={weights} size={size} />
105
- <View pointerEvents='none' style={[styles.ring, { borderRadius: size / 2 }, getSwatchRing(hex, value === hex, themeColors.outlineVariant)]}>
106
- {value === hex && <Icon source='check' size={20} color={getContrastColor(hex)} />}
118
+ <Pie palette={swatchPalettes[index]} weights={weights} size={size} />
119
+ <View pointerEvents='none' style={[styles.ring, { borderRadius: size / 2 }, getSwatchRing(swatchPalettes[index].primary, value === hex, themeColors.outlineVariant)]}>
120
+ {value === hex && <Icon source='check' size={20} color={getContrastColor(swatchPalettes[index].primary)} />}
107
121
  </View>
108
122
  </TouchableOpacity>
109
123
  ))}
package/src/index.ts CHANGED
@@ -31,7 +31,7 @@ export { usePaperDefaults } from './PaperDefaultsContext'
31
31
  export type { ThemeState } from './redux/themeSlice'
32
32
  export { createThemeReducer, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer } from './redux/themeSlice'
33
33
  export type { ProviderProps } from './ThemeProvider'
34
- export { Provider, useBlurModule, useMaskedViewModule, useNavBarContext, useReanimatedModule } from './ThemeProvider'
34
+ export { Provider, useBlurModule, useMaskedViewModule, useNavBarContext, usePaletteTone, useReanimatedModule } from './ThemeProvider'
35
35
  export type { ThemeSettings, ThemeSettingsContextType } from './ThemeSettingsContext'
36
36
  export { defaultThemeSettings, ThemeSettingsContext } from './ThemeSettingsContext'
37
37
  export type { AutoPaperTheme, SemanticColorRoles, ThemeAppearance } from './useComputedTheme'
@@ -41,9 +41,14 @@ export type { ThemeBridgeProps } from './useThemeBridgeProps'
41
41
  export { useThemeBridgeProps } from './useThemeBridgeProps'
42
42
  export { useThemeSettings } from './useThemeSettings'
43
43
  export { getBlendedColor } from './utils/getBlendedColor'
44
+ export type { ChalkboardToneOptions, PaletteTone } from './utils/getChalkboardColor'
45
+ export { createChalkboardTone, getChalkboardColor } from './utils/getChalkboardColor'
44
46
  export type { ColorRoles } from './utils/getColorRoles'
45
47
  export { getColorRoles } from './utils/getColorRoles'
48
+ export { getContrastRatio, getLuminance } from './utils/getContrastRatio'
46
49
  export { getHex } from './utils/getHex'
50
+ export type { Oklch } from './utils/getOklch'
51
+ export { getOklch, getOklchHex } from './utils/getOklch'
47
52
  export type { RGB } from './utils/getRgb'
48
53
  export { getRgb } from './utils/getRgb'
49
54
  export { getContrastColor } from './utils/getSwatchContrast'