jfs-components 0.0.74 → 0.0.77

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/lib/commonjs/components/ActionFooter/ActionFooter.js +147 -82
  3. package/lib/commonjs/components/Avatar/Avatar.js +20 -0
  4. package/lib/commonjs/components/Badge/Badge.js +23 -0
  5. package/lib/commonjs/components/Button/Button.js +37 -0
  6. package/lib/commonjs/components/IconButton/IconButton.js +20 -0
  7. package/lib/commonjs/components/Image/Image.js +26 -1
  8. package/lib/commonjs/components/LottiePlayer/LottiePlayer.js +116 -0
  9. package/lib/commonjs/components/LottiePlayer/LottiePlayer.web.js +82 -0
  10. package/lib/commonjs/components/LottiePlayer/loadNativeLottieView.js +74 -0
  11. package/lib/commonjs/components/LottiePlayer/loadWebLottieView.js +50 -0
  12. package/lib/commonjs/components/PageHero/PageHero.js +41 -5
  13. package/lib/commonjs/components/RechargeCard/RechargeCard.js +32 -17
  14. package/lib/commonjs/components/Text/Text.js +31 -1
  15. package/lib/commonjs/components/index.js +7 -0
  16. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  17. package/lib/commonjs/icons/Icon.js +16 -0
  18. package/lib/commonjs/icons/registry.js +1 -1
  19. package/lib/commonjs/index.js +12 -0
  20. package/lib/commonjs/skeleton/Skeleton.js +234 -0
  21. package/lib/commonjs/skeleton/SkeletonGroup.js +140 -0
  22. package/lib/commonjs/skeleton/index.js +58 -0
  23. package/lib/commonjs/skeleton/shimmer-tokens.js +189 -0
  24. package/lib/commonjs/skeleton/useReducedMotion.js +64 -0
  25. package/lib/module/components/ActionFooter/ActionFooter.js +146 -82
  26. package/lib/module/components/Avatar/Avatar.js +19 -0
  27. package/lib/module/components/Badge/Badge.js +23 -0
  28. package/lib/module/components/Button/Button.js +37 -0
  29. package/lib/module/components/IconButton/IconButton.js +20 -0
  30. package/lib/module/components/Image/Image.js +25 -1
  31. package/lib/module/components/LottiePlayer/LottiePlayer.js +111 -0
  32. package/lib/module/components/LottiePlayer/LottiePlayer.web.js +77 -0
  33. package/lib/module/components/LottiePlayer/loadNativeLottieView.js +69 -0
  34. package/lib/module/components/LottiePlayer/loadWebLottieView.js +45 -0
  35. package/lib/module/components/PageHero/PageHero.js +41 -5
  36. package/lib/module/components/RechargeCard/RechargeCard.js +33 -17
  37. package/lib/module/components/Text/Text.js +31 -1
  38. package/lib/module/components/index.js +1 -0
  39. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  40. package/lib/module/icons/Icon.js +16 -0
  41. package/lib/module/icons/registry.js +1 -1
  42. package/lib/module/index.js +2 -1
  43. package/lib/module/skeleton/Skeleton.js +229 -0
  44. package/lib/module/skeleton/SkeletonGroup.js +133 -0
  45. package/lib/module/skeleton/index.js +6 -0
  46. package/lib/module/skeleton/shimmer-tokens.js +181 -0
  47. package/lib/module/skeleton/useReducedMotion.js +61 -0
  48. package/lib/typescript/src/components/ActionFooter/ActionFooter.d.ts +26 -21
  49. package/lib/typescript/src/components/Avatar/Avatar.d.ts +7 -1
  50. package/lib/typescript/src/components/Badge/Badge.d.ts +7 -1
  51. package/lib/typescript/src/components/Button/Button.d.ts +8 -1
  52. package/lib/typescript/src/components/IconButton/IconButton.d.ts +7 -1
  53. package/lib/typescript/src/components/Image/Image.d.ts +8 -1
  54. package/lib/typescript/src/components/LottiePlayer/LottiePlayer.d.ts +85 -0
  55. package/lib/typescript/src/components/LottiePlayer/LottiePlayer.web.d.ts +28 -0
  56. package/lib/typescript/src/components/LottiePlayer/loadNativeLottieView.d.ts +11 -0
  57. package/lib/typescript/src/components/LottiePlayer/loadWebLottieView.d.ts +11 -0
  58. package/lib/typescript/src/components/PageHero/PageHero.d.ts +31 -5
  59. package/lib/typescript/src/components/Text/Text.d.ts +20 -1
  60. package/lib/typescript/src/components/index.d.ts +1 -0
  61. package/lib/typescript/src/icons/Icon.d.ts +7 -1
  62. package/lib/typescript/src/icons/registry.d.ts +1 -1
  63. package/lib/typescript/src/index.d.ts +1 -0
  64. package/lib/typescript/src/skeleton/Skeleton.d.ts +60 -0
  65. package/lib/typescript/src/skeleton/SkeletonGroup.d.ts +78 -0
  66. package/lib/typescript/src/skeleton/index.d.ts +5 -0
  67. package/lib/typescript/src/skeleton/shimmer-tokens.d.ts +160 -0
  68. package/lib/typescript/src/skeleton/useReducedMotion.d.ts +15 -0
  69. package/package.json +11 -1
  70. package/src/components/ActionFooter/ActionFooter.tsx +152 -86
  71. package/src/components/Avatar/Avatar.tsx +26 -0
  72. package/src/components/Badge/Badge.tsx +27 -0
  73. package/src/components/Button/Button.tsx +40 -0
  74. package/src/components/IconButton/IconButton.tsx +27 -0
  75. package/src/components/Image/Image.tsx +25 -0
  76. package/src/components/LottiePlayer/LottiePlayer.tsx +145 -0
  77. package/src/components/LottiePlayer/LottiePlayer.web.tsx +94 -0
  78. package/src/components/LottiePlayer/loadNativeLottieView.tsx +87 -0
  79. package/src/components/LottiePlayer/loadWebLottieView.tsx +64 -0
  80. package/src/components/PageHero/PageHero.tsx +61 -4
  81. package/src/components/RechargeCard/RechargeCard.tsx +32 -24
  82. package/src/components/Text/Text.tsx +54 -0
  83. package/src/components/index.ts +1 -0
  84. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  85. package/src/icons/Icon.tsx +17 -0
  86. package/src/icons/registry.ts +1 -1
  87. package/src/index.ts +1 -0
  88. package/src/skeleton/Skeleton.tsx +298 -0
  89. package/src/skeleton/SkeletonGroup.tsx +193 -0
  90. package/src/skeleton/index.ts +10 -0
  91. package/src/skeleton/shimmer-tokens.ts +221 -0
  92. package/src/skeleton/useReducedMotion.ts +72 -0
@@ -4,4 +4,5 @@ export * from './components';
4
4
  export * as Icons from './icons';
5
5
  export * from './design-tokens';
6
6
  export * from './Containers';
7
- export * from './utils';
7
+ export * from './utils';
8
+ export * from './skeleton';
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+
3
+ import React, { useId, useMemo, useState } from 'react';
4
+ import { StyleSheet, View } from 'react-native';
5
+ import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated';
6
+ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from 'react-native-svg';
7
+ import { getVariableByName } from '../design-tokens/figma-variables-resolver';
8
+ import { SHIMMER, SHIMMER_GRADIENT_STOPS, SKELETON_FALLBACK, SKELETON_TOKEN, computeShimmerMotion, staggerDelayMs } from './shimmer-tokens';
9
+ import { useSkeleton, useStaggerIndex } from './SkeletonGroup';
10
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
+ // Solid-white overlay used by reduced-motion mode (also acts as the safe
12
+ // fallback when SVG can't render or when the box hasn't been measured yet).
13
+ // Allocated once at module scope so per-render allocation is zero.
14
+ const SOLID_OVERLAY_COLOR = 'rgba(255, 255, 255, 1)';
15
+
16
+ // `pointerEvents: 'none'` lives on the style (not the deprecated prop) so it
17
+ // works on both native and React Native Web without warnings.
18
+ const absoluteFillStyle = {
19
+ ...StyleSheet.absoluteFillObject,
20
+ overflow: 'hidden',
21
+ pointerEvents: 'none'
22
+ };
23
+ const solidOverlayStyle = {
24
+ ...StyleSheet.absoluteFillObject,
25
+ backgroundColor: SOLID_OVERLAY_COLOR
26
+ };
27
+
28
+ /**
29
+ * Atomic skeleton placeholder. Renders a base rectangle (background colour +
30
+ * radius per kind) plus an animated overlay that produces the shimmer.
31
+ *
32
+ * Two visual modes share the same engine:
33
+ *
34
+ * - Normal mode (gradient: true): a soft 135° band translates diagonally
35
+ * across the box end-to-end. Its alpha inside the moving band varies
36
+ * 33% → 100% → 33% via gradient stops. The sawtooth clock travels from
37
+ * fully off-screen (−overshoot) to fully off-screen (+overshoot) so the
38
+ * transparent gradient tails clear the box before the loop resets.
39
+ *
40
+ * - Reduced-motion mode (gradient: false): a solid white overlay covers the
41
+ * box and pulses opacity 33% → 100% → 33% in place, with no translation,
42
+ * ease-in-out timing and no stagger.
43
+ *
44
+ * Critical for cross-platform consistency:
45
+ *
46
+ * - The SVG gradient uses `gradientUnits="userSpaceOnUse"` with absolute
47
+ * pixel coordinates that have Δx === Δy. This guarantees the visual angle
48
+ * is exactly 45° in screen pixels (= 135° CSS) on ANY aspect ratio. With
49
+ * the default `objectBoundingBox` units, the gradient line is normalised
50
+ * to the box and tilts off-angle on non-square boxes.
51
+ *
52
+ * - The whole animated overlay sits inside a clipping parent (`overflow:
53
+ * hidden` + `borderRadius`). Translating an `Animated.View` is GPU-cheap
54
+ * on both iOS/Android (Reanimated UI thread) and on web (CSS transforms).
55
+ *
56
+ * - The SVG `id` for the gradient is per-instance (`useId`) so multiple
57
+ * skeletons on a web page don't collide on `url(#…)` resolution.
58
+ */
59
+ function SkeletonImpl({
60
+ kind = 'other',
61
+ width,
62
+ height,
63
+ style,
64
+ modes
65
+ }) {
66
+ const {
67
+ active,
68
+ reducedMotion,
69
+ clock
70
+ } = useSkeleton();
71
+ const index = useStaggerIndex();
72
+ const reactId = useId();
73
+ // SVG ids must match `^[A-Za-z][A-Za-z0-9_.-]*$`; React's useId returns
74
+ // colons which are illegal in fragment refs, so strip them.
75
+ const gradientId = `jfsSkeletonGradient-${reactId.replace(/[^A-Za-z0-9_-]/g, '')}`;
76
+
77
+ // Measured pixel size of the base box. The moving shimmer geometry depends
78
+ // on real layout so we capture it via onLayout. Before the first layout
79
+ // pass W and H are 0 and we render no overlay (the next render after layout
80
+ // fills it in).
81
+ const [layout, setLayout] = useState({
82
+ width: 0,
83
+ height: 0
84
+ });
85
+ const radiusTokenName = SKELETON_TOKEN.radius[kind];
86
+ const radius = getVariableByName(radiusTokenName, modes ?? {}) ?? SKELETON_FALLBACK.radius[kind];
87
+ const backgroundColor = getVariableByName(SKELETON_TOKEN.background, modes ?? {}) ?? SKELETON_FALLBACK.backgroundColor;
88
+ const spec = reducedMotion ? SHIMMER.reduced : SHIMMER.normal;
89
+ // Convert the per-item stagger into a phase offset in [0, 1) (relative to
90
+ // one cycle). The sawtooth clock means a phase shift simply slides the
91
+ // start point of each skeleton's sweep, producing the cascade.
92
+ const phaseOffset = spec.durationMs > 0 ? staggerDelayMs(index, spec) / spec.durationMs : 0;
93
+ const baseStyle = useMemo(() => ({
94
+ backgroundColor,
95
+ borderRadius: radius,
96
+ overflow: 'hidden',
97
+ ...(width !== undefined ? {
98
+ width
99
+ } : {}),
100
+ ...(height !== undefined ? {
101
+ height
102
+ } : {})
103
+ }), [backgroundColor, radius, width, height]);
104
+
105
+ // --- Moving-shimmer geometry --------------------------------------------
106
+ //
107
+ // Overlay is 2× the box diagonal so the gradient still covers the clipped
108
+ // area at extreme translations. Overshoot (travel beyond each corner) is
109
+ // derived from the gradient stop layout via `computeShimmerMotion`.
110
+ const W = layout.width;
111
+ const H = layout.height;
112
+ const diag = Math.sqrt(W * W + H * H);
113
+ const overlaySize = diag * 2;
114
+ const motion = useMemo(() => computeShimmerMotion(W, H, overlaySize, SHIMMER_GRADIENT_STOPS), [W, H, overlaySize]);
115
+ const padX = motion?.padX ?? 0;
116
+ const padY = motion?.padY ?? 0;
117
+ const kStart = motion?.kStart ?? 0;
118
+ const kEnd = motion?.kEnd ?? 0;
119
+ const kTravel = kEnd - kStart;
120
+ const overlayAnimatedStyle = useAnimatedStyle(() => {
121
+ const t = (clock.value + phaseOffset) % 1;
122
+ if (spec.gradient) {
123
+ if (kTravel === 0) {
124
+ // Pre-layout: keep the overlay invisible so we don't flash a
125
+ // mis-sized gradient before onLayout fires.
126
+ return {
127
+ transform: [{
128
+ translateX: 0
129
+ }, {
130
+ translateY: 0
131
+ }],
132
+ opacity: 0
133
+ };
134
+ }
135
+ // Linear sawtooth: t = 0 → fully before entry, t = 1 → fully after exit.
136
+ // Equivalent to normalised phase p = −O + t·(1 + 2O) where O is the
137
+ // gradient-derived overshoot fraction from `computeShimmerMotion`.
138
+ const k = kStart + t * kTravel;
139
+ return {
140
+ transform: [{
141
+ translateX: k
142
+ }, {
143
+ translateY: k
144
+ }],
145
+ opacity: 1
146
+ };
147
+ }
148
+ // Reduced motion: stationary solid overlay, opacity triangle wave.
149
+ const wave = t < 0.5 ? t * 2 : (1 - t) * 2;
150
+ const [lo, hi] = spec.opacityRange;
151
+ return {
152
+ transform: [{
153
+ translateX: 0
154
+ }, {
155
+ translateY: 0
156
+ }],
157
+ opacity: interpolate(wave, [0, 1], [lo, hi])
158
+ };
159
+ }, [phaseOffset, kStart, kTravel, spec.gradient, spec.opacityRange[0], spec.opacityRange[1]]);
160
+ const gradientOverlayContainerStyle = useMemo(() => ({
161
+ position: 'absolute',
162
+ left: -padX,
163
+ top: -padY,
164
+ width: overlaySize,
165
+ height: overlaySize,
166
+ pointerEvents: 'none'
167
+ }), [padX, padY, overlaySize]);
168
+ if (!active) {
169
+ // Render nothing when the group is inactive. This lets users sprinkle
170
+ // <Skeleton /> blocks unconditionally inside their loaded UI without
171
+ // having to manually gate them — only the active group flips them on.
172
+ return null;
173
+ }
174
+ const onLayout = e => {
175
+ const {
176
+ width: w,
177
+ height: h
178
+ } = e.nativeEvent.layout;
179
+ if (w !== layout.width || h !== layout.height) {
180
+ setLayout({
181
+ width: w,
182
+ height: h
183
+ });
184
+ }
185
+ };
186
+ return /*#__PURE__*/_jsx(View, {
187
+ style: [baseStyle, style],
188
+ accessibilityElementsHidden: true,
189
+ importantForAccessibility: "no-hide-descendants"
190
+ // Screen readers announce a loading state once via the group's owner;
191
+ // each individual skeleton is purely decorative.
192
+ ,
193
+ accessibilityRole: "none",
194
+ onLayout: onLayout,
195
+ children: spec.gradient ? layout.width > 0 ? /*#__PURE__*/_jsx(Animated.View, {
196
+ style: [gradientOverlayContainerStyle, overlayAnimatedStyle],
197
+ children: /*#__PURE__*/_jsxs(Svg, {
198
+ width: overlaySize,
199
+ height: overlaySize,
200
+ children: [/*#__PURE__*/_jsx(Defs, {
201
+ children: /*#__PURE__*/_jsx(SvgLinearGradient, {
202
+ id: gradientId,
203
+ x1: 0,
204
+ y1: 0,
205
+ x2: overlaySize,
206
+ y2: overlaySize,
207
+ gradientUnits: "userSpaceOnUse",
208
+ children: SHIMMER_GRADIENT_STOPS.map((stop, i) => /*#__PURE__*/_jsx(Stop, {
209
+ offset: String(stop.offset),
210
+ stopColor: "#ffffff",
211
+ stopOpacity: stop.opacity
212
+ }, `${stop.offset}-${i}`))
213
+ })
214
+ }), /*#__PURE__*/_jsx(Rect, {
215
+ width: overlaySize,
216
+ height: overlaySize,
217
+ fill: `url(#${gradientId})`
218
+ })]
219
+ })
220
+ }) : null : /*#__PURE__*/_jsx(Animated.View, {
221
+ style: [absoluteFillStyle, overlayAnimatedStyle],
222
+ children: /*#__PURE__*/_jsx(View, {
223
+ style: solidOverlayStyle
224
+ })
225
+ })
226
+ });
227
+ }
228
+ const Skeleton = /*#__PURE__*/React.memo(SkeletonImpl);
229
+ export default Skeleton;
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+
3
+ import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
4
+ import { cancelAnimation, Easing, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
5
+ import { SHIMMER } from './shimmer-tokens';
6
+ import { useReducedMotion as useSystemReducedMotion } from './useReducedMotion';
7
+
8
+ /**
9
+ * Shape of the value carried by the SkeletonContext.
10
+ *
11
+ * The provider owns ONE Reanimated shared value per group; every child
12
+ * `<Skeleton>` reads it on the UI thread and computes its own opacity from
13
+ * `(clock + perItemPhaseOffset)`. That keeps per-skeleton work bounded to a
14
+ * single `useAnimatedStyle` and avoids per-block JS timers.
15
+ */
16
+ import { jsx as _jsx } from "react/jsx-runtime";
17
+ const SkeletonContext = /*#__PURE__*/createContext(null);
18
+ /**
19
+ * Provider that flips an entire subtree into "loading" mode and supplies the
20
+ * shared shimmer clock + stagger index source to every descendant skeleton.
21
+ *
22
+ * Usage:
23
+ * ```tsx
24
+ * <SkeletonGroup loading={!data}>
25
+ * <Card>...JFS primitives auto-skeletonize...</Card>
26
+ * </SkeletonGroup>
27
+ * ```
28
+ *
29
+ * Nesting is supported — an inner `<SkeletonGroup>` fully overrides the
30
+ * outer one for its subtree (its own clock, its own index counter).
31
+ */
32
+ export function SkeletonGroup({
33
+ loading,
34
+ reducedMotion: reducedMotionOverride,
35
+ children
36
+ }) {
37
+ const systemReducedMotion = useSystemReducedMotion();
38
+ const reducedMotion = reducedMotionOverride ?? systemReducedMotion;
39
+ const clock = useSharedValue(0);
40
+
41
+ // The duration depends on whether we're in reduced-motion mode; we
42
+ // intentionally restart the loop when either `loading` or `reducedMotion`
43
+ // changes so the new timing takes effect immediately.
44
+ useEffect(() => {
45
+ if (!loading) {
46
+ cancelAnimation(clock);
47
+ clock.value = 0;
48
+ return;
49
+ }
50
+ const spec = reducedMotion ? SHIMMER.reduced : SHIMMER.normal;
51
+ const easing = reducedMotion ? Easing.inOut(Easing.ease) : Easing.linear;
52
+ clock.value = 0;
53
+ // Sawtooth loop (third arg = false). One-way ramp 0 -> 1, reset, repeat.
54
+ // The moving shimmer band traverses the box on each 0 -> 1 ramp; with the
55
+ // band's gradient fully faded at both extremes of the sweep, the reset is
56
+ // visually imperceptible.
57
+ clock.value = withRepeat(withTiming(1, {
58
+ duration: spec.durationMs,
59
+ easing
60
+ }), -1, false);
61
+ return () => {
62
+ cancelAnimation(clock);
63
+ };
64
+ }, [loading, reducedMotion, clock]);
65
+
66
+ // Stagger index counter — reset on each (re)mount so reorders and toggles
67
+ // get a fresh, deterministic ordering. The counter lives in a ref so
68
+ // React's commit phase doesn't shuffle it.
69
+ const indexRef = useRef(0);
70
+ // Reset whenever loading flips on, so a freshly-displayed group cascades
71
+ // from index 0 again rather than picking up stale numbers.
72
+ useEffect(() => {
73
+ if (loading) indexRef.current = 0;
74
+ }, [loading]);
75
+ const value = useMemo(() => ({
76
+ active: loading,
77
+ reducedMotion,
78
+ clock,
79
+ nextIndex: () => {
80
+ const i = indexRef.current;
81
+ indexRef.current = i + 1;
82
+ return i;
83
+ }
84
+ }), [loading, reducedMotion, clock]);
85
+ return /*#__PURE__*/_jsx(SkeletonContext.Provider, {
86
+ value: value,
87
+ children: children
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Internal hook used by skeleton-aware primitives (and by `<Skeleton>`
93
+ * itself) to read the surrounding context.
94
+ *
95
+ * Always returns a non-null value: when called outside any provider, the
96
+ * returned object has `active: false` so the consumer falls through to its
97
+ * normal render path. The fallback clock is a no-op shared value so any
98
+ * direct `<Skeleton>` usage outside a group still has something safe to read.
99
+ */
100
+ export function useSkeleton() {
101
+ const ctx = useContext(SkeletonContext);
102
+ // Hook order must be stable — always allocate the fallback even when ctx
103
+ // exists; React only reads from `ctx` when it's non-null below.
104
+ const fallbackClock = useSharedValue(0);
105
+ const fallbackIndexRef = useRef(0);
106
+ const fallback = useMemo(() => ({
107
+ active: false,
108
+ reducedMotion: false,
109
+ clock: fallbackClock,
110
+ nextIndex: () => {
111
+ const i = fallbackIndexRef.current;
112
+ fallbackIndexRef.current = i + 1;
113
+ return i;
114
+ }
115
+ }), [fallbackClock]);
116
+ return ctx ?? fallback;
117
+ }
118
+
119
+ /**
120
+ * Convenience wrapper around `useSkeleton().nextIndex()` that pins the
121
+ * returned value across re-renders. Each `<Skeleton>` calls this once on
122
+ * mount; subsequent renders re-use the same index from the closure.
123
+ */
124
+ export function useStaggerIndex() {
125
+ const {
126
+ nextIndex
127
+ } = useSkeleton();
128
+ const indexRef = useRef(null);
129
+ if (indexRef.current === null) {
130
+ indexRef.current = nextIndex();
131
+ }
132
+ return indexRef.current;
133
+ }
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+
3
+ export { default as Skeleton } from './Skeleton';
4
+ export { SkeletonGroup, useSkeleton, useStaggerIndex } from './SkeletonGroup';
5
+ export { useReducedMotion } from './useReducedMotion';
6
+ export { SHIMMER, SKELETON_TOKEN, SKELETON_FALLBACK } from './shimmer-tokens';
@@ -0,0 +1,181 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Single source of truth for the skeleton shimmer behaviour spec.
5
+ *
6
+ * The two modes mirror the documented design behaviour:
7
+ *
8
+ * Normal:
9
+ * - 1.5s linear sawtooth cycle (band sweeps end-to-end then resets)
10
+ * - 135° gradient (top-left -> bottom-right), fixed angle on any aspect
11
+ * ratio via SVG userSpaceOnUse coordinates
12
+ * - Band alpha animates 33% -> 100% -> 33% across the band core;
13
+ * transparent fades at the gradient edges hide the sawtooth reset
14
+ * - 50–100ms per-item stagger
15
+ *
16
+ * Reduced motion (system-level OS toggle):
17
+ * - 3.0s ease-in-out cycle
18
+ * - No translation — solid white overlay opacity pulses 33% -> 100% in
19
+ * place (`opacityRange` below is consumed in this path)
20
+ * - No gradient
21
+ * - No stagger
22
+ *
23
+ * Centralising the spec here keeps `Skeleton.tsx` purely presentational and
24
+ * lets us tune the behaviour in one place if design ever updates it.
25
+ */
26
+
27
+ export const SHIMMER = {
28
+ normal: {
29
+ durationMs: 1500,
30
+ staggerMsRange: [50, 100],
31
+ gradient: true,
32
+ opacityRange: [0.33, 1.0]
33
+ },
34
+ reduced: {
35
+ durationMs: 3000,
36
+ staggerMsRange: [0, 0],
37
+ gradient: false,
38
+ opacityRange: [0.33, 1.0]
39
+ },
40
+ /**
41
+ * Gradient angle in degrees, measured the CSS way: 0deg points "up", 90deg
42
+ * "right", 135deg therefore points down-right (top-left corner to
43
+ * bottom-right corner).
44
+ */
45
+ gradientAngleDeg: 135,
46
+ /**
47
+ * Hard cap on the cumulative stagger delay so very long lists don't drift
48
+ * out forever; the wave wraps after this many ms.
49
+ */
50
+ maxStaggerMs: 600
51
+ };
52
+
53
+ /**
54
+ * Token names — referenced via `getVariableByName(...)` so the existing
55
+ * design-token resolver does its job (caching, mode resolution, etc.).
56
+ *
57
+ * The four tokens already live in the Figma export at
58
+ * `src/design-tokens/Coin Variables-variables-full.json`:
59
+ *
60
+ * - `bg/defaultSkeleton` -> base color for every skeleton block
61
+ * - `cornerRadius/defaultSkeleton` -> text & "other" (pill: 9999)
62
+ * - `cornerRadius/imageSkeleton` -> images (10)
63
+ * - `cornerRadius/badgeSkeleton` -> badges (4)
64
+ */
65
+ export const SKELETON_TOKEN = {
66
+ background: 'bg/defaultSkeleton',
67
+ radius: {
68
+ text: 'cornerRadius/defaultSkeleton',
69
+ image: 'cornerRadius/imageSkeleton',
70
+ badge: 'cornerRadius/badgeSkeleton',
71
+ other: 'cornerRadius/defaultSkeleton'
72
+ }
73
+ };
74
+
75
+ /**
76
+ * Fallback constants used when the token resolver is unavailable (tests,
77
+ * SSR, etc.). Match the current Figma values.
78
+ */
79
+ export const SKELETON_FALLBACK = {
80
+ backgroundColor: 'rgb(245, 245, 246)',
81
+ radius: {
82
+ text: 9999,
83
+ image: 10,
84
+ badge: 4,
85
+ other: 9999
86
+ }
87
+ };
88
+
89
+ /**
90
+ * Compute a stable per-item delay from a 0-based registration index.
91
+ *
92
+ * We pick the midpoint of the documented range (75ms) so the cascade is
93
+ * deterministic for snapshot tests and visually identical between renders.
94
+ * The total delay is then wrapped at `maxStaggerMs` so deep lists don't
95
+ * drift past the cap.
96
+ */
97
+ export function staggerDelayMs(index, mode) {
98
+ const [min, max] = mode.staggerMsRange;
99
+ if (max <= 0) return 0;
100
+ const step = (min + max) / 2;
101
+ return index * step % SHIMMER.maxStaggerMs;
102
+ }
103
+
104
+ /** One stop on the moving shimmer gradient (offset 0–1 along the 135° axis). */
105
+
106
+ /**
107
+ * Gradient stops for the normal-mode moving band. The peak sits at 0.5; fully
108
+ * transparent tails at 0 and 1. The 0.30 / 0.70 stops mark where the band
109
+ * reaches the documented 33 % alpha.
110
+ */
111
+ export const SHIMMER_GRADIENT_STOPS = [{
112
+ offset: 0,
113
+ opacity: 0
114
+ }, {
115
+ offset: 0.30,
116
+ opacity: 0.33
117
+ }, {
118
+ offset: 0.50,
119
+ opacity: 1
120
+ }, {
121
+ offset: 0.70,
122
+ opacity: 0.33
123
+ }, {
124
+ offset: 1.0,
125
+ opacity: 0
126
+ }];
127
+
128
+ /** Offset (0–1) of the brightest point on the gradient line. */
129
+ export function gradientPeakOffset(stops = SHIMMER_GRADIENT_STOPS) {
130
+ const peak = stops.find(s => s.opacity === 1);
131
+ return peak?.offset ?? 0.5;
132
+ }
133
+
134
+ /**
135
+ * How far the gradient extends from the peak to a fully transparent stop,
136
+ * expressed as a fraction of the gradient line (0–1). With the default stops
137
+ * this is 0.5 (peak at 0.5, transparent at 0 and 1).
138
+ *
139
+ * This value drives the overshoot: the band must travel this fraction of a
140
+ * full corner-to-corner sweep *beyond* each corner so the soft transparent
141
+ * tails fully clear the box before the sawtooth reset.
142
+ */
143
+ export function gradientTransparentExtent(stops = SHIMMER_GRADIENT_STOPS) {
144
+ const peak = gradientPeakOffset(stops);
145
+ const transparent = stops.filter(s => s.opacity === 0);
146
+ if (transparent.length === 0) return 0.5;
147
+ return Math.max(...transparent.map(s => Math.abs(s.offset - peak)));
148
+ }
149
+ /**
150
+ * Derive the moving-shimmer geometry from box size, overlay size, and the
151
+ * gradient stop layout.
152
+ *
153
+ * Coordinate model (135° / down-right):
154
+ * - Peak stripe world diagonal sum: (W + H) / 2 + 2k
155
+ * - Gradient offset 0 → 1 spans 2 × overlaySize in diagonal-sum units
156
+ * - Transparent tail beyond peak: fadeExtent × 2 × overlaySize / 2
157
+ * = fadeExtent × overlaySize in k units
158
+ *
159
+ * The sawtooth clock maps linearly kStart → kEnd so t = 0 and t = 1 both
160
+ * land with the entire gradient outside the box — no jitter on reset.
161
+ */
162
+ export function computeShimmerMotion(width, height, overlaySize, stops = SHIMMER_GRADIENT_STOPS) {
163
+ if (width <= 0 || height <= 0 || overlaySize <= 0) return null;
164
+ const fadeExtent = gradientTransparentExtent(stops);
165
+ const cornerTravelK = (width + height) / 2;
166
+ const baseSweepHalfK = cornerTravelK / 2;
167
+ const fadeBeyondPeakK = fadeExtent * overlaySize;
168
+ const kStart = -baseSweepHalfK - fadeBeyondPeakK;
169
+ const kEnd = baseSweepHalfK + fadeBeyondPeakK;
170
+ const overshootFraction = cornerTravelK > 0 ? fadeBeyondPeakK / cornerTravelK : 0;
171
+ return {
172
+ overlaySize,
173
+ padX: (overlaySize - width) / 2,
174
+ padY: (overlaySize - height) / 2,
175
+ kStart,
176
+ kEnd,
177
+ overshootFraction,
178
+ cornerTravelK,
179
+ fadeBeyondPeakK
180
+ };
181
+ }
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { AccessibilityInfo, Platform } from 'react-native';
5
+
6
+ /**
7
+ * Cross-platform "prefers reduced motion" hook.
8
+ *
9
+ * - Native: reads `AccessibilityInfo.isReduceMotionEnabled()` and subscribes
10
+ * to `reduceMotionChanged` events so the value stays live as the user
11
+ * toggles the OS setting.
12
+ * - Web: uses `window.matchMedia('(prefers-reduced-motion: reduce)')`,
13
+ * subscribing to its `change` event.
14
+ * - Anywhere either API is missing: returns `false` (no reduction).
15
+ *
16
+ * The hook never throws — every native API access is defensively guarded so
17
+ * the skeleton system stays safe in tests, SSR, and constrained sandboxes.
18
+ */
19
+ export function useReducedMotion() {
20
+ const [reduced, setReduced] = useState(false);
21
+ useEffect(() => {
22
+ let cancelled = false;
23
+ if (Platform.OS === 'web') {
24
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
25
+ return;
26
+ }
27
+ const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
28
+ const update = matches => {
29
+ if (!cancelled) setReduced(matches);
30
+ };
31
+ update(mql.matches);
32
+ const listener = e => update(e.matches);
33
+ if (typeof mql.addEventListener === 'function') {
34
+ mql.addEventListener('change', listener);
35
+ return () => {
36
+ cancelled = true;
37
+ mql.removeEventListener('change', listener);
38
+ };
39
+ }
40
+ const legacyMql = mql;
41
+ legacyMql.addListener?.(listener);
42
+ return () => {
43
+ cancelled = true;
44
+ legacyMql.removeListener?.(listener);
45
+ };
46
+ }
47
+ if (typeof AccessibilityInfo?.isReduceMotionEnabled === 'function') {
48
+ AccessibilityInfo.isReduceMotionEnabled().then(value => {
49
+ if (!cancelled) setReduced(!!value);
50
+ }).catch(() => {});
51
+ }
52
+ const sub = typeof AccessibilityInfo?.addEventListener === 'function' ? AccessibilityInfo.addEventListener('reduceMotionChanged', value => {
53
+ if (!cancelled) setReduced(!!value);
54
+ }) : null;
55
+ return () => {
56
+ cancelled = true;
57
+ sub?.remove?.();
58
+ };
59
+ }, []);
60
+ return reduced;
61
+ }