@opndev/react-native-events 0.0.15 → 0.0.17

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/Changes CHANGED
@@ -1,5 +1,18 @@
1
1
  Revision history for @opndev/opndev-react-native-events
2
2
 
3
+ 0.0.17 2026-06-28 04:29:54Z
4
+
5
+ * Add gradient panel and proper implementation of contrast color, not just
6
+ "pick white or black"
7
+ * Fix HeroScreenFixed to be actually fixed to the content
8
+
9
+ 0.0.16 2026-06-27 23:25:11Z
10
+
11
+ * Add widgets for dynamic and static content:
12
+ news now is dynamic and some other bits are static because we don't need to
13
+ do call to a backend to decide what scheduling or rules there are. Offline
14
+ mode is cool too
15
+
3
16
  0.0.15 2026-06-26 22:56:26Z
4
17
 
5
18
  * Add debugRouteState() to debug export to debug route states more easily
package/README.md CHANGED
@@ -5,7 +5,7 @@ SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
5
5
  SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
6
6
  -->
7
7
 
8
- # Welcome to @opndev/opndev-react-native-events
8
+ # Welcome to @opndev/react-native-events
9
9
 
10
10
  Reusable React Native / Expo components for event-style apps.
11
11
 
@@ -26,7 +26,7 @@ This package provides a small set of building blocks for:
26
26
  ## Installation
27
27
 
28
28
  ```bash
29
- npm install @opndev/opndev-react-native-events
29
+ npm install @opndev/react-native-events
30
30
  ```
31
31
 
32
32
  Peer dependencies are expected to be installed by the consuming app.
@@ -2,99 +2,21 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
4
 
5
- /**
6
- * News API cache time-to-live.
7
- */
8
- const TTL_MS = 15 * 60 * 1000;
9
-
10
- /**
11
- * In-memory cache for news API responses.
12
- */
13
- const cache = new Map();
14
-
15
- /**
16
- * Builds a cache key for a given URI and bearer token.
17
- *
18
- * @param {string} uri
19
- * @param {string} [bearerToken]
20
- *
21
- * @returns {string}
22
- */
23
- function getCacheKey(uri, bearerToken) {
24
- return `${uri}::${bearerToken || ''}`;
25
- }
26
-
27
- /**
28
- * Returns true when a cache entry is still fresh.
29
- *
30
- * @param {object} entry
31
- *
32
- * @returns {boolean}
33
- */
34
- function isFresh(entry) {
35
- if (!entry) {
36
- return false;
37
- }
38
-
39
- return (Date.now() - entry.fetchedAt) < TTL_MS;
40
- }
41
-
42
- /**
43
- * Fetches JSON from the news API with optional bearer token support.
44
- *
45
- * Cached responses are reused for 15 minutes unless `force` is set.
46
- *
47
- * @param {object} args
48
- * @param {string} args.uri
49
- * @param {string} [args.bearerToken]
50
- * @param {boolean} [args.force]
51
- *
52
- * @returns {Promise<any>}
53
- */
54
- export async function fetchNewsJson({
55
- uri,
56
- bearerToken,
57
- force = false,
58
- }) {
59
- const key = getCacheKey(uri, bearerToken);
60
- const entry = cache.get(key);
61
-
62
- if (!force && isFresh(entry)) {
63
- return entry.data;
64
- }
65
-
66
- const headers = {};
67
- if (bearerToken) {
68
- headers.Authorization = `Bearer ${bearerToken}`;
69
- }
70
-
71
- const res = await fetch(uri, {
72
- method: 'GET',
73
- headers,
74
- });
75
-
76
- const data = await res.json();
77
-
78
- cache.set(key, {
79
- data,
80
- fetchedAt: Date.now(),
81
- });
82
-
83
- return data;
5
+ // This used to be its own standalone cache implementation. The
6
+ // logic was always fully generic underneath (just a TTL cache
7
+ // around fetch + bearer auth) — it's been moved to
8
+ // @opndev/react-native-events as useJsonApiData's default fetcher,
9
+ // shared with RemoteDataWidget. This file now just re-exports under
10
+ // the original names so NewsListScreen/NewsItemScreen don't need to
11
+ // change, and there's only one actual cache implementation rather
12
+ // than two that could drift apart.
13
+
14
+ import { fetchJson, clearJsonCache } from '@opndev/react-native-events/widgets';
15
+
16
+ export function fetchNewsJson({ uri, bearerToken, force }) {
17
+ return fetchJson({ uri, bearerToken, force });
84
18
  }
85
19
 
86
- /**
87
- * Clears the cached response for a specific URI.
88
- *
89
- * @param {object} args
90
- * @param {string} args.uri
91
- * @param {string} [args.bearerToken]
92
- *
93
- * @returns {void}
94
- */
95
- export function clearNewsCache({
96
- uri,
97
- bearerToken,
98
- }) {
99
- cache.delete(getCacheKey(uri, bearerToken));
20
+ export function clearNewsCache({ uri, bearerToken }) {
21
+ return clearJsonCache({ uri, bearerToken });
100
22
  }
@@ -15,11 +15,8 @@ const defaultStyle = StyleSheet.create({
15
15
  container: {
16
16
  flex: 1,
17
17
  },
18
- headerWrap: {
18
+ header: {
19
19
  width: '100%',
20
- position: 'absolute',
21
- top: 0,
22
- left: 0,
23
20
  },
24
21
  imageInner: {
25
22
  position: 'absolute',
@@ -31,12 +28,24 @@ const defaultStyle = StyleSheet.create({
31
28
  /**
32
29
  * HeroScreenFixed
33
30
  *
34
- * Header image stays pinned in place; only the content below it
35
- * scrolls. The image never overlaps the status bar a solid
36
- * headerBackgroundColor strip (sized to the safe-area top inset,
37
- * via useHeroHeaderHeight) sits above it. Body content is wrapped
38
- * the same way ParallaxScrollView wraps its children — padding: 32,
39
- * gap: 16 — so spacing matches the parallax variant exactly.
31
+ * Header image is a normal, in-flow element at the top of the
32
+ * scroll — it scrolls away together with the content below it, at
33
+ * the same rate, with no pinning and no animation. Same structural
34
+ * shape as HeroScreenParallax, just without the scroll-driven
35
+ * scale/translateY transform "parallax minus the animation."
36
+ *
37
+ * "Fixed" means fixed to the content below it, not fixed to the
38
+ * screen — the previous version of this file pinned the header via
39
+ * position:absolute, which was actually the same idea as
40
+ * HeroScreenOverlay, not what "Fixed" was meant to mean. Rebuilt
41
+ * from scratch; this file had no usages anywhere yet.
42
+ *
43
+ * ContentComponent's role matches HeroScreenParallax's: it wraps
44
+ * the body content for layout purposes (defaults to View, NOT a
45
+ * scroll container) — the actual scrolling is owned by the outer
46
+ * ScrollView, hardcoded, not swappable. If you were expecting
47
+ * ContentComponent to default to ScrollView (the old behavior),
48
+ * that's the one real API change here.
40
49
  *
41
50
  * @param {object} props
42
51
  * @param {React.ReactNode} props.children Normal body content, and/or <CarouselScreen> elements for slides.
@@ -48,7 +57,7 @@ const defaultStyle = StyleSheet.create({
48
57
  * @param {string} [props.backgroundColor]
49
58
  * @param {string} [props.headerBackgroundColor]
50
59
  * @param {number} [props.headerHeight] Visible image height (excludes the safe-area inset)
51
- * @param {React.ComponentType} [props.ContentComponent]
60
+ * @param {React.ComponentType} [props.ContentComponent] Defaults to View — wraps body content, does NOT own scrolling.
52
61
  * @param {object} [props.containerStyle]
53
62
  * @param {object} [props.headerStyle]
54
63
  * @param {object} [props.contentStyle]
@@ -65,7 +74,7 @@ export default function HeroScreenFixed({
65
74
  backgroundColor,
66
75
  headerBackgroundColor,
67
76
  headerHeight = 220,
68
- ContentComponent = ScrollView,
77
+ ContentComponent = View,
69
78
  containerStyle,
70
79
  headerStyle,
71
80
  contentStyle,
@@ -74,7 +83,7 @@ export default function HeroScreenFixed({
74
83
  const { slideElements, bodyChildren } = splitCarouselChildren(children);
75
84
 
76
85
  return (
77
- <View
86
+ <ScrollView
78
87
  style={[
79
88
  defaultStyle.container,
80
89
  backgroundColor ? { backgroundColor } : null,
@@ -83,7 +92,7 @@ export default function HeroScreenFixed({
83
92
  >
84
93
  <View
85
94
  style={[
86
- defaultStyle.headerWrap,
95
+ defaultStyle.header,
87
96
  { height: totalHeaderHeight },
88
97
  headerBackgroundColor ? { backgroundColor: headerBackgroundColor } : null,
89
98
  headerStyle,
@@ -105,15 +114,10 @@ export default function HeroScreenFixed({
105
114
  </View>
106
115
  </View>
107
116
 
108
- <ContentComponent
109
- style={{ flex: 1 }}
110
- contentContainerStyle={{ paddingTop: totalHeaderHeight }}
111
- >
112
- <View style={[heroContentStyle, contentStyle]}>
113
- <HeaderOverlay headerOverlay={headerOverlay} />
114
- {bodyChildren}
115
- </View>
117
+ <ContentComponent style={[heroContentStyle, contentStyle]}>
118
+ <HeaderOverlay headerOverlay={headerOverlay} />
119
+ {bodyChildren}
116
120
  </ContentComponent>
117
- </View>
121
+ </ScrollView>
118
122
  );
119
123
  }
@@ -1,3 +1,7 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
1
5
  import { useState, isValidElement, Children } from 'react';
2
6
  import { View, Image, Pressable, StyleSheet } from 'react-native';
3
7
  import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
@@ -0,0 +1,150 @@
1
+ import React from 'react';
2
+ import { View, Pressable, StyleSheet } from 'react-native';
3
+ import { LinearGradient } from 'expo-linear-gradient';
4
+ import { contrastColor } from '../utils/colors';
5
+ import { PanelContrastContext } from '../hooks/use-panel-contrast';
6
+
7
+ // Six named directions — end is always the geometric opposite of
8
+ // start, so only one end of each axis needs naming. Want the
9
+ // reverse (e.g. bottom-left to top-right)? Swap the order of
10
+ // `colors` instead of asking for a 7th/8th direction keyword.
11
+ const DIRECTIONS = {
12
+ top: { start: { x: 0, y: 0 }, end: { x: 0, y: 1 } },
13
+ bottom: { start: { x: 0, y: 1 }, end: { x: 0, y: 0 } },
14
+ left: { start: { x: 0, y: 0 }, end: { x: 1, y: 0 } },
15
+ right: { start: { x: 1, y: 0 }, end: { x: 0, y: 0 } },
16
+ 'top-left': { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } },
17
+ 'top-right': { start: { x: 1, y: 0 }, end: { x: 0, y: 1 } },
18
+ };
19
+
20
+ // Local for now, not added to lib/utils/colors.js since I haven't
21
+ // seen that file's full current content — move it there later if
22
+ // you want it shared. Only handles 6-digit hex (#RRGGBB); named
23
+ // colors ('white') or rgba() strings aren't parsed.
24
+ function hexToRgb(hex) {
25
+ const clean = hex.replace('#', '');
26
+ const value = parseInt(clean, 16);
27
+ return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
28
+ }
29
+
30
+ function rgbToHex({ r, g, b }) {
31
+ return '#' + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
32
+ }
33
+
34
+ function averageColor(colors) {
35
+ const rgbs = colors.map(hexToRgb);
36
+ const sum = rgbs.reduce((acc, c) => ({ r: acc.r + c.r, g: acc.g + c.g, b: acc.b + c.b }), { r: 0, g: 0, b: 0 });
37
+ const n = rgbs.length;
38
+ return rgbToHex({ r: sum.r / n, g: sum.g / n, b: sum.b / n });
39
+ }
40
+
41
+ const defaultStyle = StyleSheet.create({
42
+ shadowWrap: {
43
+ borderRadius: 16,
44
+ },
45
+ shadowOn: {
46
+ elevation: 4,
47
+ shadowColor: '#000',
48
+ shadowOffset: { width: 0, height: 2 },
49
+ shadowOpacity: 0.15,
50
+ shadowRadius: 6,
51
+ },
52
+ surface: {
53
+ borderRadius: 16,
54
+ overflow: 'hidden',
55
+ },
56
+ content: {
57
+ padding: 16,
58
+ },
59
+ });
60
+
61
+ /**
62
+ * GradientPanel
63
+ *
64
+ * Same shape and behavior as Panel — sizes to its content, no
65
+ * forced layout on children, same shadow-split structure (an outer
66
+ * shadow-casting view, an inner clipping view, for the same reason
67
+ * Panel needs it: a shadow renders outside a view's own bounds, so
68
+ * it can't live on the same view that clips with overflow:hidden).
69
+ *
70
+ * The only difference: a gradient background instead of a flat
71
+ * `backgroundColor`. Stays theme-agnostic like Panel — pass raw
72
+ * resolved colors (e.g. ['#FFFFFF', theme.primary]), this component
73
+ * doesn't reach into your theme itself.
74
+ *
75
+ * Automatically derives a contrast text color from the AVERAGE of
76
+ * its `colors` stops (via contrastColor — same hue/saturation,
77
+ * contrasting lightness, not flat black/white), made available to
78
+ * anything rendered inside via PanelContrastContext — same
79
+ * mechanism Panel uses. This is a heuristic, not exact — a fade
80
+ * from white to a dark color averages to something mid-tone, which
81
+ * may not actually read well at either literal end of the gradient.
82
+ * Override with `contrastTextColor` if the heuristic guesses wrong.
83
+ *
84
+ * @param {object} props
85
+ * @param {React.ReactNode} props.children
86
+ * @param {string[]} [props.colors] Gradient stops, e.g. ['#FFFFFF', theme.primary]. Defaults to ['#FFFFFF', '#FFFFFF'] (solid white) if omitted.
87
+ * @param {'top'|'bottom'|'left'|'right'|'top-left'|'top-right'} [props.direction] Defaults to 'top'. Takes precedence over start/end if both are given.
88
+ * @param {{x: number, y: number}} [props.start] Raw escape hatch, ignored if `direction` is set.
89
+ * @param {{x: number, y: number}} [props.end] Raw escape hatch, ignored if `direction` is set.
90
+ * @param {number[]} [props.locations] Where each color stop lands (0-1). Optional — omit for an even spread.
91
+ * @param {string} [props.contrastTextColor] Overrides the automatically-derived (average-based) contrast color.
92
+ * @param {boolean} [props.shadow] Floating-card drop shadow. Defaults to false.
93
+ * @param {Function} [props.onPress] If provided, the whole panel becomes pressable.
94
+ * @param {object} [props.style] Style for the outer (shadow-casting) wrapper.
95
+ * @param {object} [props.contentStyle] Style for the inner content wrapper (default padding: 16).
96
+ *
97
+ * @returns {JSX.Element}
98
+ */
99
+ export default function GradientPanel({
100
+ children,
101
+ colors = ['#FFFFFF', '#FFFFFF'],
102
+ direction = 'top',
103
+ start,
104
+ end,
105
+ locations,
106
+ contrastTextColor,
107
+ shadow = false,
108
+ onPress,
109
+ style,
110
+ contentStyle,
111
+ }) {
112
+ const resolvedDirection = DIRECTIONS[direction] ?? {
113
+ start: start ?? DIRECTIONS.top.start,
114
+ end: end ?? DIRECTIONS.top.end,
115
+ };
116
+ const resolvedContrast = contrastTextColor ?? contrastColor(averageColor(colors));
117
+
118
+ const content = (
119
+ <LinearGradient
120
+ colors={colors}
121
+ start={resolvedDirection.start}
122
+ end={resolvedDirection.end}
123
+ locations={locations}
124
+ style={defaultStyle.surface}
125
+ >
126
+ <PanelContrastContext.Provider value={resolvedContrast}>
127
+ <View style={[defaultStyle.content, contentStyle]}>
128
+ {children}
129
+ </View>
130
+ </PanelContrastContext.Provider>
131
+ </LinearGradient>
132
+ );
133
+
134
+ const wrapperStyle = [
135
+ defaultStyle.shadowWrap,
136
+ shadow ? defaultStyle.shadowOn : null,
137
+ shadow ? { backgroundColor: colors[0] } : null,
138
+ style,
139
+ ];
140
+
141
+ if (onPress) {
142
+ return (
143
+ <Pressable onPress={onPress} style={wrapperStyle}>
144
+ {content}
145
+ </Pressable>
146
+ );
147
+ }
148
+
149
+ return <View style={wrapperStyle}>{content}</View>;
150
+ }
@@ -1,9 +1,10 @@
1
1
  // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
-
5
4
  import React from 'react';
6
5
  import { View, Pressable, StyleSheet } from 'react-native';
6
+ import { contrastColor } from '../utils/colors';
7
+ import { PanelContrastContext } from '../hooks/use-panel-contrast';
7
8
 
8
9
  const defaultStyle = StyleSheet.create({
9
10
  shadowWrap: {
@@ -43,9 +44,19 @@ const defaultStyle = StyleSheet.create({
43
44
  * split means `shadow` can be toggled on/off with no other changes
44
45
  * needed, and panels that don't use it are unaffected.
45
46
  *
47
+ * Automatically derives a contrast text color from `backgroundColor`
48
+ * (via contrastColor) and makes it available to anything rendered
49
+ * inside, via PanelContrastContext — so e.g. DataListWidget picks up
50
+ * a correct default text color for whatever Panel it's sitting in,
51
+ * with zero per-usage color wiring. Pass `contrastTextColor`
52
+ * yourself to override the automatic guess; anything inside can
53
+ * still override further for individual pieces of text (e.g.
54
+ * DataListWidget's own itemTextStyle/titleStyle still win over this).
55
+ *
46
56
  * @param {object} props
47
57
  * @param {React.ReactNode} props.children
48
58
  * @param {string} [props.backgroundColor]
59
+ * @param {string} [props.contrastTextColor] Overrides the automatically-derived contrast color.
49
60
  * @param {boolean} [props.shadow] Floating-card drop shadow. Defaults to false.
50
61
  * @param {Function} [props.onPress] If provided, the whole panel becomes pressable.
51
62
  * @param {object} [props.style] Style for the outer (shadow-casting) wrapper.
@@ -56,20 +67,26 @@ const defaultStyle = StyleSheet.create({
56
67
  export default function Panel({
57
68
  children,
58
69
  backgroundColor,
70
+ contrastTextColor,
59
71
  shadow = false,
60
72
  onPress,
61
73
  style,
62
74
  contentStyle,
63
75
  }) {
76
+ const resolvedContrast = contrastTextColor
77
+ ?? (backgroundColor ? contrastColor(backgroundColor) : null);
78
+
64
79
  const surfaceStyle = [
65
80
  defaultStyle.surface,
66
81
  backgroundColor ? { backgroundColor } : null,
67
82
  ];
68
83
 
69
84
  const content = (
70
- <View style={[defaultStyle.content, contentStyle]}>
71
- {children}
72
- </View>
85
+ <PanelContrastContext.Provider value={resolvedContrast}>
86
+ <View style={[defaultStyle.content, contentStyle]}>
87
+ {children}
88
+ </View>
89
+ </PanelContrastContext.Provider>
73
90
  );
74
91
 
75
92
  const inner = onPress ? (
@@ -0,0 +1,97 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ import { View, Text, StyleSheet } from 'react-native';
6
+ import Panel from './panel';
7
+
8
+ const defaultStyle = StyleSheet.create({
9
+ container: {
10
+ gap: 16,
11
+ },
12
+ dayHeading: {
13
+ fontWeight: '700',
14
+ marginBottom: 8,
15
+ },
16
+ event: {
17
+ gap: 4,
18
+ marginBottom: 12,
19
+ },
20
+ eventTitle: {
21
+ fontWeight: '700',
22
+ },
23
+ });
24
+
25
+ /**
26
+ * Schedule
27
+ *
28
+ * Renders a multi-day event schedule — one Panel per day. Panels
29
+ * cycle through `washColors` in order (e.g. [primarySoft,
30
+ * secondarySoft] gives primary/secondary/primary/secondary...), so
31
+ * consecutive days are visually distinct without any per-day
32
+ * configuration needed.
33
+ *
34
+ * @param {object} props
35
+ * @param {object} props.schedule Shape: { [dateKey]: Array<{ event: string, description?: string }> }
36
+ * @param {string[]} [props.washColors] Colors cycled through per day, in order. Omit for no alternation (every Panel uses Panel's own default).
37
+ * @param {(dateKey: string) => string} [props.formatDate] Defaults to showing the raw key as-is.
38
+ * @param {React.ComponentType} [props.TextComponent] Defaults to Text.
39
+ * @param {Function} [props.onPressEvent] (event, dateKey) => void
40
+ * @param {boolean} [props.shadow] Passed through to every Panel.
41
+ * @param {object} [props.containerStyle]
42
+ * @param {object} [props.dayHeadingStyle]
43
+ * @param {object} [props.eventTitleStyle]
44
+ * @param {object} [props.eventDescriptionStyle]
45
+ *
46
+ * @returns {JSX.Element}
47
+ */
48
+ export default function Schedule({
49
+ schedule,
50
+ washColors = [],
51
+ formatDate = (dateKey) => dateKey,
52
+ TextComponent = Text,
53
+ onPressEvent,
54
+ shadow,
55
+ containerStyle,
56
+ dayHeadingStyle,
57
+ eventTitleStyle,
58
+ eventDescriptionStyle,
59
+ }) {
60
+ const days = Object.keys(schedule || {});
61
+
62
+ return (
63
+ <View style={[defaultStyle.container, containerStyle]}>
64
+ {days.map((dateKey, dayIndex) => {
65
+ const events = schedule[dateKey] || [];
66
+ const backgroundColor = washColors.length
67
+ ? washColors[dayIndex % washColors.length]
68
+ : undefined;
69
+
70
+ return (
71
+ <Panel key={dateKey} backgroundColor={backgroundColor} shadow={shadow}>
72
+ <TextComponent style={[defaultStyle.dayHeading, dayHeadingStyle]}>
73
+ {formatDate(dateKey)}
74
+ </TextComponent>
75
+
76
+ {events.map((item, eventIndex) => (
77
+ <View key={eventIndex} style={defaultStyle.event}>
78
+ <TextComponent
79
+ style={[defaultStyle.eventTitle, eventTitleStyle]}
80
+ onPress={onPressEvent ? () => onPressEvent(item, dateKey) : undefined}
81
+ >
82
+ {item.event}
83
+ </TextComponent>
84
+
85
+ {item.description ? (
86
+ <TextComponent style={eventDescriptionStyle}>
87
+ {item.description}
88
+ </TextComponent>
89
+ ) : null}
90
+ </View>
91
+ ))}
92
+ </Panel>
93
+ );
94
+ })}
95
+ </View>
96
+ );
97
+ }
package/lib/components.js CHANGED
@@ -12,6 +12,7 @@ export { default as GradientTile } from './components/gradient-tile.jsx';
12
12
  export { default as QRCodeForm } from './components/qr-code-form.jsx';
13
13
  export { default as ScaledLogo } from './components/scaled-logo.jsx';
14
14
  export { default as Panel } from './components/panel.jsx';
15
+ export { default as GradientPanel } from './components/panel-gradient.jsx';
15
16
 
16
17
  export { openUrl, openApp, openExternal } from './utils/launch.js';
17
18