@rific/auto-paper 0.12.3 → 0.13.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
@@ -601,11 +601,13 @@ var BlurModuleContext = createContext2(void 0);
601
601
  var useBlurModule = () => useContext3(BlurModuleContext);
602
602
  var ReanimatedModuleContext = createContext2(void 0);
603
603
  var useReanimatedModule = () => useContext3(ReanimatedModuleContext);
604
- function Provider({ children, defaults, expoBlur, fontFamily, initialValue, navigationBar, onChange, onNavBarChange, onReady, reanimated, statusBarProps, style }) {
605
- return /* @__PURE__ */ jsx4(ThemeSettingsProvider, { initialValue, onChange, children: /* @__PURE__ */ jsx4(ThemeProviderBody, { defaults, expoBlur, fontFamily, navigationBar, onNavBarChange, onReady, reanimated, statusBarProps, style, children }) });
604
+ var MaskedViewModuleContext = createContext2(void 0);
605
+ var useMaskedViewModule = () => useContext3(MaskedViewModuleContext);
606
+ function Provider({ children, defaults, expoBlur, fontFamily, initialValue, maskedView, navigationBar, onChange, onNavBarChange, onReady, reanimated, statusBarProps, style }) {
607
+ return /* @__PURE__ */ jsx4(ThemeSettingsProvider, { initialValue, onChange, children: /* @__PURE__ */ jsx4(ThemeProviderBody, { defaults, expoBlur, fontFamily, maskedView, navigationBar, onNavBarChange, onReady, reanimated, statusBarProps, style, children }) });
606
608
  }
607
609
  var ThemeSettingsProvider = themeSettingsProvider;
608
- function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, navigationBar, onNavBarChange, onReady, reanimated, statusBarProps, style }) {
610
+ function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, maskedView, navigationBar, onNavBarChange, onReady, reanimated, statusBarProps, style }) {
609
611
  const { settings } = useThemeSettingsInternal();
610
612
  const computedTheme = useComputedTheme(settings.appearance, settings.color, settings.harmony);
611
613
  const theme = useMemo(() => {
@@ -623,7 +625,7 @@ function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, navigatio
623
625
  if (!theme) return null;
624
626
  return /* @__PURE__ */ jsxs2(PaperProvider, { theme, children: [
625
627
  /* @__PURE__ */ jsx4(StatusBar2, { backgroundColor: theme.colors.background, barStyle: theme.dark ? "light-content" : "dark-content", ...statusBarProps }),
626
- /* @__PURE__ */ jsx4(PaperDefaultsContext.Provider, { value: defaults ?? {}, children: /* @__PURE__ */ jsx4(NavBarContext.Provider, { value: { navigationBar, onNavBarChange }, children: /* @__PURE__ */ jsx4(BlurModuleContext.Provider, { value: expoBlur, children: /* @__PURE__ */ jsx4(ReanimatedModuleContext.Provider, { value: reanimated, children: /* @__PURE__ */ jsx4(Portal.Host, { children: /* @__PURE__ */ jsx4(View, { style: [styles.flex, { backgroundColor: theme.colors.background }, style], children }) }) }) }) }) })
628
+ /* @__PURE__ */ jsx4(PaperDefaultsContext.Provider, { value: defaults ?? {}, children: /* @__PURE__ */ jsx4(NavBarContext.Provider, { value: { navigationBar, onNavBarChange }, children: /* @__PURE__ */ jsx4(BlurModuleContext.Provider, { value: expoBlur, children: /* @__PURE__ */ jsx4(ReanimatedModuleContext.Provider, { value: reanimated, children: /* @__PURE__ */ jsx4(MaskedViewModuleContext.Provider, { value: maskedView, children: /* @__PURE__ */ jsx4(Portal.Host, { children: /* @__PURE__ */ jsx4(View, { style: [styles.flex, { backgroundColor: theme.colors.background }, style], children }) }) }) }) }) }) })
627
629
  ] });
628
630
  }
629
631
  var styles = StyleSheet.create({
@@ -642,7 +644,12 @@ var blursOnAndroid = ({ blurMethod, blurTarget, experimentalBlurMethod }) => {
642
644
  };
643
645
  var ELEVATION_OPACITY = { 0: 0, 1: 0.05, 2: 0.08, 3: 0.11, 4: 0.12, 5: 0.14 };
644
646
  var VARIANT_TINT_OPACITY = { light: 0.12, dark: 0.18 };
645
- var BlurView = ({ blur = true, children, elevation, style, tintColor, tintOpacity, variant = "surface", ...props }) => {
647
+ var getWebBlurStyle = (intensity, dark) => {
648
+ const amount = Math.min(intensity, 100);
649
+ const filter = `saturate(180%) blur(${amount * 0.2}px)`;
650
+ return { backdropFilter: filter, backgroundColor: `rgba(${dark ? "25,25,25" : "249,249,249"},${amount / 100 * 0.78})`, WebkitBackdropFilter: filter };
651
+ };
652
+ var BlurView = ({ blur = true, blurMethod, blurReductionFactor, blurTarget, children, elevation, experimentalBlurMethod, intensity, style, tint: _tint, tintColor, tintOpacity, variant = "surface", ...props }) => {
646
653
  const { dark, colors } = useTheme2();
647
654
  const { settings } = useContext4(ThemeSettingsContext);
648
655
  const expoBlur = useBlurModule();
@@ -651,13 +658,13 @@ var BlurView = ({ blur = true, children, elevation, style, tintColor, tintOpacit
651
658
  const resolvedTintColor = tintColor ?? variantColor ?? (elevation !== void 0 ? colors.primary : void 0);
652
659
  const resolvedTintOpacity = tintOpacity ?? (tintColor ? dark ? 0.24 : 0.16 : variantColor ? dark ? VARIANT_TINT_OPACITY.dark : VARIANT_TINT_OPACITY.light : elevation !== void 0 ? ELEVATION_OPACITY[elevation] : 0);
653
660
  const tint = resolvedTintColor && resolvedTintOpacity > 0 ? /* @__PURE__ */ jsx5(View2, { style: [StyleSheet2.absoluteFill, { backgroundColor: resolvedTintColor, opacity: resolvedTintOpacity }] }) : null;
654
- if (blur && expoBlur && (Platform.OS !== "android" || blursOnAndroid(props)))
655
- return /* @__PURE__ */ jsxs3(expoBlur.BlurView, { ...props, tint: dark ? "dark" : "light", style, children: [
656
- /* @__PURE__ */ jsx5(View2, { style: [StyleSheet2.absoluteFill, { backgroundColor: surfaceColor, opacity: settings.blurTint }] }),
657
- tint,
658
- children
659
- ] });
660
- return /* @__PURE__ */ jsxs3(View2, { ...props, style: [{ backgroundColor: surfaceColor }, style], children: [
661
+ const blurProps = { blurMethod, blurReductionFactor, blurTarget, experimentalBlurMethod, intensity };
662
+ const blurs = blur && expoBlur !== void 0 && (Platform.OS !== "android" || blursOnAndroid(blurProps));
663
+ const web = Platform.OS === "web";
664
+ const containerStyle = !blurs ? [{ backgroundColor: surfaceColor }, style] : web ? [style, getWebBlurStyle(intensity ?? 50, dark)] : style;
665
+ return /* @__PURE__ */ jsxs3(View2, { ...props, style: containerStyle, children: [
666
+ blurs && !web ? /* @__PURE__ */ jsx5(expoBlur.BlurView, { ...blurProps, style: StyleSheet2.absoluteFill, tint: dark ? "dark" : "light" }) : null,
667
+ blurs ? /* @__PURE__ */ jsx5(View2, { style: [StyleSheet2.absoluteFill, { backgroundColor: surfaceColor, opacity: settings.blurTint }] }) : null,
661
668
  tint,
662
669
  children
663
670
  ] });
@@ -950,22 +957,104 @@ function FAB(props) {
950
957
  return /* @__PURE__ */ jsx14(PaperFAB, { ...defaults.FAB, ...props });
951
958
  }
952
959
 
960
+ // src/components/FadingScrollView.tsx
961
+ import { useCallback as useCallback2, useRef as useRef2, useState as useState6 } from "react";
962
+ import { Platform as Platform4, ScrollView, StyleSheet as StyleSheet6, View as View6 } from "react-native";
963
+ import { jsx as jsx15, jsxs as jsxs7 } from "react/jsx-runtime";
964
+ var SCROLL_FADE_HEIGHT = 24;
965
+ function FadingScrollView({ style, fadeHeight = SCROLL_FADE_HEIGHT, onScroll, onLayout, onContentSizeChange, scrollEventThrottle = 16, children, ...rest }) {
966
+ const MaskedView = useMaskedViewModule();
967
+ const sizes = useRef2({ viewport: 0, content: 0, offset: 0 });
968
+ const [edges, setEdges] = useState6(NO_EDGES);
969
+ const update = useCallback2(() => {
970
+ const { viewport, content, offset } = sizes.current;
971
+ const above = offset > 1;
972
+ const below = content - viewport - offset > 1;
973
+ setEdges((prev) => prev.above === above && prev.below === below ? prev : { above, below });
974
+ }, []);
975
+ const scroll = /* @__PURE__ */ jsx15(
976
+ ScrollView,
977
+ {
978
+ ...rest,
979
+ style: styles5.fill,
980
+ scrollEventThrottle,
981
+ onScroll: (e) => {
982
+ sizes.current.offset = e.nativeEvent.contentOffset.y;
983
+ update();
984
+ onScroll?.(e);
985
+ },
986
+ onLayout: (e) => {
987
+ sizes.current.viewport = e.nativeEvent.layout.height;
988
+ update();
989
+ onLayout?.(e);
990
+ },
991
+ onContentSizeChange: (width, height) => {
992
+ sizes.current.content = height;
993
+ update();
994
+ onContentSizeChange?.(width, height);
995
+ },
996
+ children
997
+ }
998
+ );
999
+ if (Platform4.OS === "web") return /* @__PURE__ */ jsx15(View6, { style: [styles5.box, style, (edges.above || edges.below) && webFadeStyle(fadeHeight, edges)], children: scroll });
1000
+ if (!MaskedView) return /* @__PURE__ */ jsx15(View6, { style: [styles5.box, style], children: scroll });
1001
+ return /* @__PURE__ */ jsx15(
1002
+ MaskedView,
1003
+ {
1004
+ style: [styles5.box, style],
1005
+ maskElement: /* @__PURE__ */ jsxs7(View6, { style: styles5.fill, children: [
1006
+ /* @__PURE__ */ jsx15(View6, { style: [{ height: fadeHeight }, edges.above ? FADE_IN_BAND : styles5.opaque] }),
1007
+ /* @__PURE__ */ jsx15(View6, { style: styles5.fillOpaque }),
1008
+ /* @__PURE__ */ jsx15(View6, { style: [{ height: fadeHeight }, edges.below ? FADE_OUT_BAND : styles5.opaque] })
1009
+ ] }),
1010
+ children: scroll
1011
+ }
1012
+ );
1013
+ }
1014
+ var FADE_IN_BAND = { experimental_backgroundImage: "linear-gradient(transparent, #000)" };
1015
+ var FADE_OUT_BAND = { experimental_backgroundImage: "linear-gradient(#000, transparent)" };
1016
+ var NO_EDGES = { above: false, below: false };
1017
+ function webFadeStyle(fadeHeight, edges) {
1018
+ const top = edges.above ? `transparent 0, #000 ${fadeHeight}px` : "#000 0";
1019
+ const bottom = edges.below ? `#000 calc(100% - ${fadeHeight}px), transparent 100%` : "#000 100%";
1020
+ const mask = `linear-gradient(to bottom, ${top}, ${bottom})`;
1021
+ return { maskImage: mask, WebkitMaskImage: mask };
1022
+ }
1023
+ var styles5 = StyleSheet6.create({
1024
+ // A plain ScrollView's own flex defaults, so it sizes the same in its parent.
1025
+ box: {
1026
+ flexGrow: 1,
1027
+ flexShrink: 1
1028
+ },
1029
+ fill: {
1030
+ flexGrow: 1,
1031
+ flexShrink: 1
1032
+ },
1033
+ fillOpaque: {
1034
+ backgroundColor: "#000",
1035
+ flexGrow: 1
1036
+ },
1037
+ opaque: {
1038
+ backgroundColor: "#000"
1039
+ }
1040
+ });
1041
+
953
1042
  // src/components/IconButton.tsx
954
1043
  import { IconButton as PaperIconButton, useTheme as useTheme8 } from "react-native-paper";
955
- import { jsx as jsx15 } from "react/jsx-runtime";
1044
+ import { jsx as jsx16 } from "react/jsx-runtime";
956
1045
  function IconButton({ variant, iconColor, containerColor, ...props }) {
957
1046
  const defaults = usePaperDefaults();
958
1047
  const { colors } = useTheme8();
959
1048
  const variantContainerColor = variant === "primary" ? colors.primaryContainer : variant === "secondary" ? colors.secondaryContainer : variant === "tertiary" ? colors.tertiaryContainer : variant === "surface" ? colors.surface : void 0;
960
1049
  const variantIconColor = variant === "primary" ? colors.onPrimaryContainer : variant === "secondary" ? colors.onSecondaryContainer : variant === "tertiary" ? colors.onTertiaryContainer : variant === "surface" ? colors.onSurface : void 0;
961
- return /* @__PURE__ */ jsx15(PaperIconButton, { ...defaults.IconButton, containerColor: containerColor ?? variantContainerColor, iconColor: iconColor ?? variantIconColor, ...props });
1050
+ return /* @__PURE__ */ jsx16(PaperIconButton, { ...defaults.IconButton, containerColor: containerColor ?? variantContainerColor, iconColor: iconColor ?? variantIconColor, ...props });
962
1051
  }
963
1052
 
964
1053
  // src/components/Menu.tsx
965
- import { useEffect as useEffect5, useState as useState6 } from "react";
966
- import { Animated as Animated2, Easing as Easing2, Platform as Platform4, StyleSheet as StyleSheet6 } from "react-native";
1054
+ import { useEffect as useEffect5, useState as useState7 } from "react";
1055
+ import { Animated as Animated2, Easing as Easing2, Platform as Platform5, StyleSheet as StyleSheet7 } from "react-native";
967
1056
  import { Menu as PaperMenu, useTheme as useTheme9 } from "react-native-paper";
968
- import { jsx as jsx16 } from "react/jsx-runtime";
1057
+ import { jsx as jsx17 } from "react/jsx-runtime";
969
1058
  var ANIMATION_DURATION2 = 250;
970
1059
  var EASING = Easing2.bezier(0.4, 0, 0.2, 1);
971
1060
  var DEFAULT_ELEVATION = 2;
@@ -974,24 +1063,24 @@ var SHADOW_RADIUS = [0, 3, 6, 8, 10, 12];
974
1063
  var AnimatedBlurView2 = Animated2.createAnimatedComponent(BlurView);
975
1064
  var FadingBlurView = ({ blur, children, elevation, theme: themeOverrides, visible }) => {
976
1065
  const { animation, roundness } = useTheme9(themeOverrides);
977
- const [opacity] = useState6(() => new Animated2.Value(0));
978
- const [closing, setClosing] = useState6(false);
1066
+ const [opacity] = useState7(() => new Animated2.Value(0));
1067
+ const [closing, setClosing] = useState7(false);
979
1068
  if (!visible && !closing) setClosing(true);
980
1069
  useEffect5(() => {
981
1070
  Animated2.timing(opacity, { duration: animation.scale * ANIMATION_DURATION2, easing: EASING, toValue: closing ? 0 : 1, useNativeDriver: false }).start();
982
1071
  }, [animation.scale, closing, opacity]);
983
1072
  const shadow = elevation > 0 ? { shadowColor: "#000000", shadowOffset: { height: SHADOW_HEIGHT[elevation], width: 0 }, shadowOpacity: 0.3, shadowRadius: SHADOW_RADIUS[elevation] } : null;
984
- return /* @__PURE__ */ jsx16(AnimatedBlurView2, { blur, style: [{ borderRadius: roundness, opacity }, shadow], children });
1073
+ return /* @__PURE__ */ jsx17(AnimatedBlurView2, { blur, style: [{ borderRadius: roundness, opacity }, shadow], children });
985
1074
  };
986
1075
  var MenuComponent = ({ blur: blurProp, children, ...props }) => {
987
1076
  const blur = useBlur(blurProp);
988
- const web = Platform4.OS === "web";
989
- return /* @__PURE__ */ jsx16(PaperMenu, { ...props, contentStyle: [styles5.content, web && styles5.webSurface, props.contentStyle], children: web ? /* @__PURE__ */ jsx16(FadingBlurView, { blur, elevation: props.mode === "flat" ? 0 : props.elevation ?? DEFAULT_ELEVATION, theme: props.theme, visible: props.visible, children }) : /* @__PURE__ */ jsx16(BlurView, { blur, children }) });
1077
+ const web = Platform5.OS === "web";
1078
+ return /* @__PURE__ */ jsx17(PaperMenu, { ...props, contentStyle: [styles6.content, web && styles6.webSurface, props.contentStyle], children: web ? /* @__PURE__ */ jsx17(FadingBlurView, { blur, elevation: props.mode === "flat" ? 0 : props.elevation ?? DEFAULT_ELEVATION, theme: props.theme, visible: props.visible, children }) : /* @__PURE__ */ jsx17(BlurView, { blur, children }) });
990
1079
  };
991
1080
  var Menu = Object.assign(MenuComponent, {
992
1081
  Item: PaperMenu.Item
993
1082
  });
994
- var styles5 = StyleSheet6.create({
1083
+ var styles6 = StyleSheet7.create({
995
1084
  content: { backgroundColor: "transparent", paddingVertical: 0 },
996
1085
  webSurface: { opacity: 1, shadowOpacity: 0 }
997
1086
  });
@@ -999,10 +1088,10 @@ var styles5 = StyleSheet6.create({
999
1088
  // src/components/TextInput.tsx
1000
1089
  import { forwardRef } from "react";
1001
1090
  import { TextInput as PaperTextInput } from "react-native-paper";
1002
- import { jsx as jsx17 } from "react/jsx-runtime";
1091
+ import { jsx as jsx18 } from "react/jsx-runtime";
1003
1092
  var TextInput = forwardRef(function TextInput2(props, ref) {
1004
1093
  const defaults = usePaperDefaults();
1005
- return /* @__PURE__ */ jsx17(PaperTextInput, { ref, ...defaults.TextInput, ...props });
1094
+ return /* @__PURE__ */ jsx18(PaperTextInput, { ref, ...defaults.TextInput, ...props });
1006
1095
  });
1007
1096
 
1008
1097
  // src/redux/themeSlice.ts
@@ -1130,11 +1219,13 @@ export {
1130
1219
  ColorPicker,
1131
1220
  Dialog,
1132
1221
  FAB,
1222
+ FadingScrollView,
1133
1223
  HarmonyPicker,
1134
1224
  IconButton,
1135
1225
  Menu,
1136
1226
  PalettePicker,
1137
1227
  Provider,
1228
+ SCROLL_FADE_HEIGHT,
1138
1229
  SEMANTIC_BASE_COLORS,
1139
1230
  TextInput,
1140
1231
  ThemeSettingsContext,
@@ -1162,6 +1253,7 @@ export {
1162
1253
  useBlur,
1163
1254
  useBlurModule,
1164
1255
  useComputedTheme,
1256
+ useMaskedViewModule,
1165
1257
  useNavBarContext,
1166
1258
  usePaperDefaults,
1167
1259
  useReanimatedModule,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rific/auto-paper",
3
- "version": "0.12.3",
3
+ "version": "0.13.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",
@@ -71,6 +71,7 @@
71
71
  "@infinitetoken/eslint-config": "0.2.0",
72
72
  "@infinitetoken/jest-config": "^0.4.0",
73
73
  "@infinitetoken/tsconfig": "^0.5.0",
74
+ "@react-native-masked-view/masked-view": "^0.3.2",
74
75
  "@reduxjs/toolkit": "^2.0.0",
75
76
  "@rific/core": "^0.2.0",
76
77
  "@testing-library/dom": "^10.4.1",
@@ -94,6 +95,7 @@
94
95
  "typescript": "^6.0.3"
95
96
  },
96
97
  "peerDependencies": {
98
+ "@react-native-masked-view/masked-view": ">=0.3.0",
97
99
  "@rific/core": ">=0.1.1",
98
100
  "expo-blur": ">=56.0.0",
99
101
  "expo-navigation-bar": ">=56.0.0",
@@ -103,6 +105,9 @@
103
105
  "react-native-safe-area-context": "*"
104
106
  },
105
107
  "peerDependenciesMeta": {
108
+ "@react-native-masked-view/masked-view": {
109
+ "optional": true
110
+ },
106
111
  "expo-blur": {
107
112
  "optional": true
108
113
  },
@@ -4,6 +4,7 @@ import { configureFonts, Portal, Provider as PaperProvider } from 'react-native-
4
4
 
5
5
  import type { ExpoBlurModule } from './components/BlurView'
6
6
  import type { ReanimatedModule } from './components/Dialog'
7
+ import type { MaskedViewModule } from './components/FadingScrollView'
7
8
  import type { ExpoNavigationBarModule } from './navigation-bar'
8
9
  import { type PaperDefaults, PaperDefaultsContext } from './PaperDefaultsContext'
9
10
  import { type ThemeSettings, themeSettingsProvider, useThemeSettingsInternal } from './ThemeSettingsContext'
@@ -24,18 +25,23 @@ export const useBlurModule = () => useContext(BlurModuleContext)
24
25
  const ReanimatedModuleContext = createContext<ReanimatedModule | undefined>(undefined)
25
26
  export const useReanimatedModule = () => useContext(ReanimatedModuleContext)
26
27
 
28
+ const MaskedViewModuleContext = createContext<MaskedViewModule | undefined>(undefined)
29
+ export const useMaskedViewModule = () => useContext(MaskedViewModuleContext)
30
+
27
31
  export type ProviderProps = {
28
32
  children: ReactNode
29
33
  defaults?: PaperDefaults
30
34
  /** Injects expo-blur for `<BlurView>`. Pass `import * as ExpoBlur from 'expo-blur'`; omit to always render BlurView's solid fallback. */
31
35
  expoBlur?: ExpoBlurModule
32
36
  /** Applied to every Paper typography variant (bodyLarge, headlineMedium, labelSmall, ...) via
33
- * `configureFonts`'s flat-config mode — each variant keeps its own MD3 fontSize/lineHeight/weight,
37
+ * `configureFonts`'s flat-config mode - each variant keeps its own MD3 fontSize/lineHeight/weight,
34
38
  * only fontFamily changes. The one-line way to reskin an app's whole text to a custom typeface,
35
39
  * instead of passing `fontFamily` to every individual Text/TextInput/Button across the app. Omit
36
40
  * to keep each platform's system font (MD3's own default). */
37
41
  fontFamily?: string
38
42
  initialValue?: Partial<ThemeSettings>
43
+ /** Injects @react-native-masked-view/masked-view so `<FadingScrollView>` can fade its content out above a pinned footer on iOS and Android. Pass `import MaskedView from '@react-native-masked-view/masked-view'`; omit to have it scroll without the fade there (web never needs it). */
44
+ maskedView?: MaskedViewModule
39
45
  /** Injects expo-navigation-bar so the Android nav bar icon style auto-syncs with the theme. Pass `import * as ExpoNavigationBar from 'expo-navigation-bar'`; omit (and don't pass onNavBarChange) to skip nav bar syncing entirely. */
40
46
  navigationBar?: ExpoNavigationBarModule
41
47
  onChange?: (settings: ThemeSettings) => void
@@ -48,15 +54,15 @@ export type ProviderProps = {
48
54
  }
49
55
 
50
56
  // createSettingsContext's generated Provider only exposes `settings` to descendants (via its
51
- // generated useSettings()) — it can't itself also compute the theme/gate mounting on it being
57
+ // generated useSettings()) - it can't itself also compute the theme/gate mounting on it being
52
58
  // ready, since that logic needs `settings` in scope. Provider (below) is just this thin outer
53
59
  // wrapper for the settings half; ThemeProviderBody (an actual descendant, reached via
54
60
  // useThemeSettingsInternal()) does all the theme-computation/gating work the single combined
55
61
  // component used to do directly.
56
- export function Provider({ children, defaults, expoBlur, fontFamily, initialValue, navigationBar, onChange, onNavBarChange, onReady, reanimated, statusBarProps, style }: ProviderProps) {
62
+ export function Provider({ children, defaults, expoBlur, fontFamily, initialValue, maskedView, navigationBar, onChange, onNavBarChange, onReady, reanimated, statusBarProps, style }: ProviderProps) {
57
63
  return (
58
64
  <ThemeSettingsProvider initialValue={initialValue} onChange={onChange}>
59
- <ThemeProviderBody defaults={defaults} expoBlur={expoBlur} fontFamily={fontFamily} navigationBar={navigationBar} onNavBarChange={onNavBarChange} onReady={onReady} reanimated={reanimated} statusBarProps={statusBarProps} style={style}>
65
+ <ThemeProviderBody defaults={defaults} expoBlur={expoBlur} fontFamily={fontFamily} maskedView={maskedView} navigationBar={navigationBar} onNavBarChange={onNavBarChange} onReady={onReady} reanimated={reanimated} statusBarProps={statusBarProps} style={style}>
60
66
  {children}
61
67
  </ThemeProviderBody>
62
68
  </ThemeSettingsProvider>
@@ -67,11 +73,11 @@ const ThemeSettingsProvider = themeSettingsProvider
67
73
 
68
74
  type ThemeProviderBodyProps = Omit<ProviderProps, 'initialValue' | 'onChange'>
69
75
 
70
- function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, navigationBar, onNavBarChange, onReady, reanimated, statusBarProps, style }: ThemeProviderBodyProps) {
76
+ function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, maskedView, navigationBar, onNavBarChange, onReady, reanimated, statusBarProps, style }: ThemeProviderBodyProps) {
71
77
  const { settings } = useThemeSettingsInternal()
72
78
 
73
79
  const computedTheme = useComputedTheme(settings.appearance, settings.color, settings.harmony)
74
- // Layered on top of useComputedTheme's own output rather than folded into it — that hook only
80
+ // Layered on top of useComputedTheme's own output rather than folded into it - that hook only
75
81
  // ever recomputes for a color/appearance/harmony change, and fontFamily is a completely
76
82
  // independent axis; merging it there would mean either re-running the whole color computation on
77
83
  // every fontFamily change (wasteful) or threading fontFamily through as a fourth computeTheme
@@ -99,17 +105,19 @@ function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, navigatio
99
105
  <NavBarContext.Provider value={{ navigationBar, onNavBarChange }}>
100
106
  <BlurModuleContext.Provider value={expoBlur}>
101
107
  <ReanimatedModuleContext.Provider value={reanimated}>
102
- {/* PaperProvider's own PortalHost sits above every context provided here, and Paper's
108
+ <MaskedViewModuleContext.Provider value={maskedView}>
109
+ {/* PaperProvider's own PortalHost sits above every context provided here, and Paper's
103
110
  Portal only carries its theme and settings across, so anything rendered through a
104
111
  Portal (this package's Dialog, Paper's Menu and Tooltip, or anything an app wraps
105
- in <Portal> itself) would otherwise lose expoBlur, reanimated, defaults and the nav
112
+ in <Portal> itself) would otherwise lose expoBlur, reanimated, maskedView, defaults and the nav
106
113
  bar hooks: Dialog/Menu's blur silently fell back to solid. This nested host is the
107
114
  nearest one for every Portal under Provider, so their content mounts inside all of
108
115
  these contexts instead. It wraps the styled View, not the other way round, so
109
116
  portals still cover the full area regardless of `style`. */}
110
- <Portal.Host>
111
- <View style={[styles.flex, { backgroundColor: theme.colors.background }, style]}>{children}</View>
112
- </Portal.Host>
117
+ <Portal.Host>
118
+ <View style={[styles.flex, { backgroundColor: theme.colors.background }, style]}>{children}</View>
119
+ </Portal.Host>
120
+ </MaskedViewModuleContext.Provider>
113
121
  </ReanimatedModuleContext.Provider>
114
122
  </BlurModuleContext.Provider>
115
123
  </NavBarContext.Provider>
@@ -8,7 +8,7 @@ type Props = {
8
8
 
9
9
  // AppearancePicker wired straight to the Provider's own ThemeSettings, for the common case where
10
10
  // the appearance being edited IS the whole app's own theme. Takes neither `value` nor `onChange`
11
- // — reads/writes useThemeSettings() itself, so there's nothing for a consumer to wire up. Reach
11
+ // - reads/writes useThemeSettings() itself, so there's nothing for a consumer to wire up. Reach
12
12
  // for the plain AppearancePicker instead when it needs to be controlled by something other than
13
13
  // the global theme (a local preview before committing, one-off UI unrelated to Provider).
14
14
  export const AutoAppearancePicker = (props: Props) => {
@@ -9,9 +9,9 @@ type Props = {
9
9
  blur?: boolean
10
10
  }
11
11
 
12
- // PalettePicker wired straight to the Provider's own ThemeSettings — both color AND harmony —
12
+ // PalettePicker wired straight to the Provider's own ThemeSettings - both color AND harmony -
13
13
  // for the common case where the palette being edited IS the whole app's own theme. Takes none of
14
- // `value`/`onChange`/`harmony`/`onHarmonyChange` — reads/writes useThemeSettings() itself
14
+ // `value`/`onChange`/`harmony`/`onHarmonyChange` - reads/writes useThemeSettings() itself
15
15
  // (resolveSeedColor narrows ThemeSettings.color's `string | TriadicPalette` down to the plain
16
16
  // string PalettePicker's own `value` wants). Reach for the plain PalettePicker instead when it
17
17
  // needs to be controlled by something other than the global theme.
@@ -58,7 +58,16 @@ export type BlurViewProps = ExpoBlurViewProps & {
58
58
  variant?: 'primary' | 'secondary' | 'tertiary' | 'error' | 'surface' | 'surfaceVariant'
59
59
  }
60
60
 
61
- export const BlurView = ({ blur = true, children, elevation, style, tintColor, tintOpacity, variant = 'surface', ...props }: BlurViewProps) => {
61
+ // The style expo-blur's web BlurView puts on its View (getBlurStyle in its src/BlurView.web.tsx, with
62
+ // getBackgroundColor's 'dark' and 'light' tints, the only two this component uses): a CSS
63
+ // backdrop-filter plus a translucent tint. On web BlurView applies it to its own View instead.
64
+ const getWebBlurStyle = (intensity: number, dark: boolean) => {
65
+ const amount = Math.min(intensity, 100)
66
+ const filter = `saturate(180%) blur(${amount * 0.2}px)`
67
+ return { backdropFilter: filter, backgroundColor: `rgba(${dark ? '25,25,25' : '249,249,249'},${(amount / 100) * 0.78})`, WebkitBackdropFilter: filter } as ViewStyle
68
+ }
69
+
70
+ export const BlurView = ({ blur = true, blurMethod, blurReductionFactor, blurTarget, children, elevation, experimentalBlurMethod, intensity, style, tint: _tint, tintColor, tintOpacity, variant = 'surface', ...props }: BlurViewProps) => {
62
71
  const { dark, colors } = useTheme()
63
72
  const { settings } = useContext(ThemeSettingsContext)
64
73
  const expoBlur = useBlurModule()
@@ -67,16 +76,21 @@ export const BlurView = ({ blur = true, children, elevation, style, tintColor, t
67
76
  const resolvedTintColor = tintColor ?? variantColor ?? (elevation !== undefined ? colors.primary : undefined)
68
77
  const resolvedTintOpacity = tintOpacity ?? (tintColor ? (dark ? 0.24 : 0.16) : variantColor ? (dark ? VARIANT_TINT_OPACITY.dark : VARIANT_TINT_OPACITY.light) : elevation !== undefined ? ELEVATION_OPACITY[elevation] : 0)
69
78
  const tint = resolvedTintColor && resolvedTintOpacity > 0 ? <View style={[StyleSheet.absoluteFill, { backgroundColor: resolvedTintColor, opacity: resolvedTintOpacity }]} /> : null
70
- if (blur && expoBlur && (Platform.OS !== 'android' || blursOnAndroid(props)))
71
- return (
72
- <expoBlur.BlurView {...props} tint={dark ? 'dark' : 'light'} style={style}>
73
- <View style={[StyleSheet.absoluteFill, { backgroundColor: surfaceColor, opacity: settings.blurTint }]} />
74
- {tint}
75
- {children}
76
- </expoBlur.BlurView>
77
- )
79
+ const blurProps = { blurMethod, blurReductionFactor, blurTarget, experimentalBlurMethod, intensity }
80
+ const blurs = blur && expoBlur !== undefined && (Platform.OS !== 'android' || blursOnAndroid(blurProps))
81
+ const web = Platform.OS === 'web'
82
+ // The View holding the children is the same element whether or not blur applies, so flipping
83
+ // `blur` (a Dialog or Menu following the player's live settings.blur) never remounts them: their
84
+ // state, scroll offset, focus and ripples all survive. On iOS and Android the blur is an absoluteFill
85
+ // layer behind everything else, the same structure as expo-blur's own native BlurView. On web it
86
+ // can't be: Dialog and Menu fade this View's own opacity there, and an ancestor below opacity 1 cuts
87
+ // a CSS backdrop-filter off from the page behind it, so on web this View itself is the
88
+ // backdrop-filter element, styled the way expo-blur's web BlurView styles its own.
89
+ const containerStyle = !blurs ? [{ backgroundColor: surfaceColor }, style] : web ? [style, getWebBlurStyle(intensity ?? 50, dark)] : style
78
90
  return (
79
- <View {...props} style={[{ backgroundColor: surfaceColor }, style]}>
91
+ <View {...props} style={containerStyle}>
92
+ {blurs && !web ? <expoBlur.BlurView {...blurProps} style={StyleSheet.absoluteFill} tint={dark ? 'dark' : 'light'} /> : null}
93
+ {blurs ? <View style={[StyleSheet.absoluteFill, { backgroundColor: surfaceColor, opacity: settings.blurTint }]} /> : null}
80
94
  {tint}
81
95
  {children}
82
96
  </View>
@@ -7,11 +7,11 @@ import { useBlur } from '../BlurContext'
7
7
  import { useReanimatedModule } from '../ThemeProvider'
8
8
  import { BlurView } from './BlurView'
9
9
 
10
- // Minimal local mirror of react-native-reanimated's Animated.View — same reasoning as BlurView's
10
+ // Minimal local mirror of react-native-reanimated's Animated.View - same reasoning as BlurView's
11
11
  // ExpoBlurModule: Metro doesn't rewrite a require()-in-try/catch call into its module graph inside
12
12
  // an ESM build, so the already-imported peer module is injected via <Provider reanimated={...}>
13
13
  // (see ThemeProvider.tsx) instead of required directly. Only `View` is needed here. `style: any`,
14
- // not a real style type — reanimated's actual AnimatedView only accepts its own branded
14
+ // not a real style type - reanimated's actual AnimatedView only accepts its own branded
15
15
  // AnimatedStyle shape, which this package never imports (no reanimated dependency at all), so `any`
16
16
  // is the only type that both sides (this interface and the real component passed in) can agree on.
17
17
  export interface ReanimatedModule {
@@ -23,7 +23,7 @@ export type DialogProps = Omit<PaperDialogProps, 'style'> & {
23
23
  blur?: boolean
24
24
  style?: StyleProp<ViewStyle>
25
25
  // Applied to the card's outer wrapper instead of the card itself, and only takes effect once a
26
- // reanimated module has been injected via <Provider reanimated={...}> — otherwise ignored. `style`
26
+ // reanimated module has been injected via <Provider reanimated={...}> - otherwise ignored. `style`
27
27
  // above is a plain object read once per render, so a fast-changing value (e.g. tracking the
28
28
  // keyboard height every frame) driven through it needs a full JS re-render per update to paint at
29
29
  // all; wiring the same value through `animatedStyle` (e.g. the result of Reanimated's
@@ -0,0 +1,127 @@
1
+ import { type ComponentType, type ReactElement, type ReactNode, useCallback, useRef, useState } from 'react'
2
+ import { type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, Platform, ScrollView, type ScrollViewProps, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
3
+
4
+ import { useMaskedViewModule } from '../ThemeProvider'
5
+
6
+ // Minimal local shape of @react-native-masked-view/masked-view's default export, the same way
7
+ // ExpoBlurModule stands in for expo-blur: the optional peer's real types are never imported, so a
8
+ // consumer that never installed it doesn't need them. <Provider maskedView={MaskedView}> hands it in.
9
+ export type MaskedViewModule = ComponentType<{ children?: ReactNode; maskElement: ReactElement; style?: StyleProp<ViewStyle> }>
10
+
11
+ // How far in from each edge a FadingScrollView's content fades out while there is more past that edge.
12
+ export const SCROLL_FADE_HEIGHT = 24
13
+
14
+ export type FadingScrollViewProps = Omit<ScrollViewProps, 'style'> & {
15
+ // Placed on the outer box (the flex item), so a caller's flexGrow/flexShrink/alignSelf work as they
16
+ // did on a plain ScrollView.
17
+ style?: StyleProp<ViewStyle>
18
+ fadeHeight?: number
19
+ }
20
+
21
+ // A vertical ScrollView whose content fades to transparent along an edge while there is more to scroll
22
+ // past it: at the bottom, the cue that rows continue below a pinned footer, like a dialog's Done
23
+ // button; at the top, the same once rows have scrolled up past the edge (Jay, 2026-10-15: one treatment
24
+ // at both ends instead of a hard cut at the top). It masks
25
+ // the content itself rather than painting anything over it, so whatever is behind (a blurred
26
+ // Dialog/BlurView surface, a plain one, light or dark) shows through unchanged: no band, no seam. (A
27
+ // second BlurView as a frosted footer was tried in @tastic/hud first and left a seam on iOS and web: a
28
+ // blur nested in a blurred card can't frost the card's own content.) Once the end is reached the fade
29
+ // goes away, and the top fade only shows once scrolled down, so neither the last row nor the first
30
+ // (a dialog's header, say) is ever dimmed at rest.
31
+ //
32
+ // iOS/Android: a MaskedView (from <Provider maskedView={...}>) whose top and bottom bands are React
33
+ // Native's native linear gradient (experimental_backgroundImage). Without one passed in, content just scrolls
34
+ // with no fade. Web: react-native-masked-view's web build renders only the mask element (it doesn't
35
+ // mask), so the outer box gets a CSS mask-image instead, which react-native-web passes through; no
36
+ // module needed there.
37
+ export function FadingScrollView({ style, fadeHeight = SCROLL_FADE_HEIGHT, onScroll, onLayout, onContentSizeChange, scrollEventThrottle = 16, children, ...rest }: FadingScrollViewProps) {
38
+ const MaskedView = useMaskedViewModule()
39
+ // Sizes live in a ref and only the yes/no answers are state, so scrolling re-renders nothing until a
40
+ // fade has to appear or go.
41
+ const sizes = useRef({ viewport: 0, content: 0, offset: 0 })
42
+ const [edges, setEdges] = useState(NO_EDGES)
43
+ const update = useCallback(() => {
44
+ const { viewport, content, offset } = sizes.current
45
+ const above = offset > 1
46
+ const below = content - viewport - offset > 1
47
+ setEdges((prev) => (prev.above === above && prev.below === below ? prev : { above, below }))
48
+ }, [])
49
+
50
+ const scroll = (
51
+ <ScrollView
52
+ {...rest}
53
+ style={styles.fill}
54
+ scrollEventThrottle={scrollEventThrottle}
55
+ onScroll={(e: NativeSyntheticEvent<NativeScrollEvent>) => {
56
+ sizes.current.offset = e.nativeEvent.contentOffset.y
57
+ update()
58
+ onScroll?.(e)
59
+ }}
60
+ onLayout={(e: LayoutChangeEvent) => {
61
+ sizes.current.viewport = e.nativeEvent.layout.height
62
+ update()
63
+ onLayout?.(e)
64
+ }}
65
+ onContentSizeChange={(width: number, height: number) => {
66
+ sizes.current.content = height
67
+ update()
68
+ onContentSizeChange?.(width, height)
69
+ }}
70
+ >
71
+ {children}
72
+ </ScrollView>
73
+ )
74
+
75
+ if (Platform.OS === 'web') return <View style={[styles.box, style, (edges.above || edges.below) && webFadeStyle(fadeHeight, edges)]}>{scroll}</View>
76
+ if (!MaskedView) return <View style={[styles.box, style]}>{scroll}</View>
77
+
78
+ return (
79
+ <MaskedView
80
+ style={[styles.box, style]}
81
+ maskElement={
82
+ <View style={styles.fill}>
83
+ <View style={[{ height: fadeHeight }, edges.above ? FADE_IN_BAND : styles.opaque]} />
84
+ <View style={styles.fillOpaque} />
85
+ <View style={[{ height: fadeHeight }, edges.below ? FADE_OUT_BAND : styles.opaque]} />
86
+ </View>
87
+ }
88
+ >
89
+ {scroll}
90
+ </MaskedView>
91
+ )
92
+ }
93
+
94
+ // React Native's native linear gradient (0.77+, the New Architecture; the fleet's apps are on 0.86), not
95
+ // in this package's own 0.76 dev types, hence the cast. A mask's alpha is what shows: opaque keeps the
96
+ // content, transparent hides it.
97
+ const FADE_IN_BAND = { experimental_backgroundImage: 'linear-gradient(transparent, #000)' } as unknown as ViewStyle
98
+ const FADE_OUT_BAND = { experimental_backgroundImage: 'linear-gradient(#000, transparent)' } as unknown as ViewStyle
99
+
100
+ const NO_EDGES = { above: false, below: false }
101
+
102
+ // mask-image isn't in React Native's style types, but react-native-web passes it to the DOM as is.
103
+ function webFadeStyle(fadeHeight: number, edges: { above: boolean; below: boolean }): ViewStyle {
104
+ const top = edges.above ? `transparent 0, #000 ${fadeHeight}px` : '#000 0'
105
+ const bottom = edges.below ? `#000 calc(100% - ${fadeHeight}px), transparent 100%` : '#000 100%'
106
+ const mask = `linear-gradient(to bottom, ${top}, ${bottom})`
107
+ return { maskImage: mask, WebkitMaskImage: mask } as unknown as ViewStyle
108
+ }
109
+
110
+ const styles = StyleSheet.create({
111
+ // A plain ScrollView's own flex defaults, so it sizes the same in its parent.
112
+ box: {
113
+ flexGrow: 1,
114
+ flexShrink: 1
115
+ },
116
+ fill: {
117
+ flexGrow: 1,
118
+ flexShrink: 1
119
+ },
120
+ fillOpaque: {
121
+ backgroundColor: '#000',
122
+ flexGrow: 1
123
+ },
124
+ opaque: {
125
+ backgroundColor: '#000'
126
+ }
127
+ })
@@ -13,12 +13,12 @@ const HARMONIES: { label: string; value: ColorHarmony; icon: string }[] = [
13
13
 
14
14
  // Paper's own SegmentedButtonItem hardcodes `minWidth: 76` per segment (sized for a labeled text
15
15
  // button), which 6 of never fit inside PalettePicker's own dialog card (capped at 400, and
16
- // narrower still once its padding and safe-area margins are subtracted) — they just overflow past
16
+ // narrower still once its padding and safe-area margins are subtracted) - they just overflow past
17
17
  // its `overflow: hidden` edge instead of shrinking. A fixed smaller guess (say 44) only moves the
18
- // breakpoint rather than removing it — 6x44 still exceeds the card's content width on an ordinary
18
+ // breakpoint rather than removing it - 6x44 still exceeds the card's content width on an ordinary
19
19
  // ~360-375px phone. Zeroing the floor instead lets each segment's own `flex: 1` divide whatever
20
20
  // width IS available with nothing left to enforce a minimum, so 6 of them mathematically can never
21
- // exceed their container at any screen size — same "shrink to fit, never overflow" contract
21
+ // exceed their container at any screen size - same "shrink to fit, never overflow" contract
22
22
  // useSwatchGrid.ts's own SWATCH_GRID_COLUMNS already uses for the swatches below this row. Left
23
23
  // untouched in labeled mode, where the wider floor is still doing its job of fitting the text.
24
24
  const COMPACT_MIN_WIDTH = 0
@@ -26,13 +26,13 @@ const COMPACT_MIN_WIDTH = 0
26
26
  type Props = {
27
27
  value: ColorHarmony
28
28
  onChange: (harmony: ColorHarmony) => void
29
- // Hides text labels, leaving just the icon per segment — mirrors AppearancePicker's own
29
+ // Hides text labels, leaving just the icon per segment - mirrors AppearancePicker's own
30
30
  // showLabels, useful here since there are 6 segments to fit instead of 3.
31
31
  // accessibilityLabel is always set regardless, so screen readers still announce the full
32
32
  // harmony name either way.
33
33
  showLabels?: boolean
34
34
  // Per-segment fill, e.g. theme.colors.secondary/onSecondary for the checked segment and
35
- // secondaryContainer/onSecondaryContainer for the rest — matches Paper's own checked/
35
+ // secondaryContainer/onSecondaryContainer for the rest - matches Paper's own checked/
36
36
  // unchecked + container color vocabulary. Omit to fall back to Paper's default segmented-
37
37
  // button styling.
38
38
  checkedColor?: string
@@ -58,7 +58,7 @@ type Props = {
58
58
  harmony?: ColorHarmony
59
59
  // Opt-in: passing this turns on a compact, icon-only HarmonyPicker row inside the dialog, above
60
60
  // the swatch grid, so every swatch's own Pie preview reshapes live as harmony changes. Omit it
61
- // and this renders exactly as a plain PalettePicker always has — same trigger, same dialog,
61
+ // and this renders exactly as a plain PalettePicker always has - same trigger, same dialog,
62
62
  // harmony fixed at whatever the `harmony` prop says. Gated on the callback itself rather than a
63
63
  // separate boolean so there's no way to pass one without the other.
64
64
  onHarmonyChange?: (harmony: ColorHarmony) => void