panelui-native 0.82.0 → 0.84.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.
Files changed (54) hide show
  1. package/lib/module/components/bottom-sheet/index.js +111 -16
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/circular-text/circular-text-geometry.js +63 -0
  4. package/lib/module/components/circular-text/circular-text-geometry.js.map +1 -0
  5. package/lib/module/components/circular-text/index.js +146 -0
  6. package/lib/module/components/circular-text/index.js.map +1 -0
  7. package/lib/module/components/image-generation/dot-field.js +66 -9
  8. package/lib/module/components/image-generation/dot-field.js.map +1 -1
  9. package/lib/module/components/image-generation/index.js +65 -23
  10. package/lib/module/components/image-generation/index.js.map +1 -1
  11. package/lib/module/components/marquee/index.js +12 -5
  12. package/lib/module/components/marquee/index.js.map +1 -1
  13. package/lib/module/components/panelside/index.js +15 -417
  14. package/lib/module/components/panelside/index.js.map +1 -1
  15. package/lib/module/components/popover/index.js +119 -7
  16. package/lib/module/components/popover/index.js.map +1 -1
  17. package/lib/module/icons/index.js +24 -4
  18. package/lib/module/icons/index.js.map +1 -1
  19. package/lib/module/index.js +1 -0
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/native/index.js +52 -0
  22. package/lib/module/native/index.js.map +1 -1
  23. package/lib/typescript/src/components/bottom-sheet/index.d.ts +17 -1
  24. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  25. package/lib/typescript/src/components/circular-text/circular-text-geometry.d.ts +39 -0
  26. package/lib/typescript/src/components/circular-text/circular-text-geometry.d.ts.map +1 -0
  27. package/lib/typescript/src/components/circular-text/index.d.ts +69 -0
  28. package/lib/typescript/src/components/circular-text/index.d.ts.map +1 -0
  29. package/lib/typescript/src/components/image-generation/dot-field.d.ts +36 -1
  30. package/lib/typescript/src/components/image-generation/dot-field.d.ts.map +1 -1
  31. package/lib/typescript/src/components/image-generation/index.d.ts +16 -8
  32. package/lib/typescript/src/components/image-generation/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  34. package/lib/typescript/src/components/panelside/index.d.ts +10 -202
  35. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  36. package/lib/typescript/src/components/popover/index.d.ts +25 -1
  37. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  38. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  39. package/lib/typescript/src/index.d.ts +2 -1
  40. package/lib/typescript/src/index.d.ts.map +1 -1
  41. package/lib/typescript/src/native/index.d.ts +66 -0
  42. package/lib/typescript/src/native/index.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/src/components/bottom-sheet/index.tsx +139 -16
  45. package/src/components/circular-text/circular-text-geometry.ts +80 -0
  46. package/src/components/circular-text/index.tsx +217 -0
  47. package/src/components/image-generation/dot-field.ts +65 -9
  48. package/src/components/image-generation/index.tsx +67 -18
  49. package/src/components/marquee/index.tsx +12 -5
  50. package/src/components/panelside/index.tsx +16 -604
  51. package/src/components/popover/index.tsx +146 -4
  52. package/src/icons/index.tsx +23 -2
  53. package/src/index.ts +1 -6
  54. package/src/native/index.ts +100 -0
@@ -38,7 +38,7 @@ import Animated, {
38
38
  } from 'react-native-reanimated';
39
39
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
40
40
  import { tv } from 'tailwind-variants';
41
- import { getNativeUI } from '../../native';
41
+ import { getComposeModifiers, getNativeUI, getSwiftUIModifiers } from '../../native';
42
42
  import { XIcon } from '../../icons';
43
43
  import { ModalPortal } from '../../primitives/portal';
44
44
  import { Scrim } from '../../primitives/scrim';
@@ -149,6 +149,22 @@ export interface BottomSheetProps {
149
149
  * nearest of `half` / `full`.
150
150
  */
151
151
  snapPoints?: ('half' | 'full' | { fraction: number } | { height: number })[];
152
+ /**
153
+ * Paint the native sheet a solid colour instead of the material the platform
154
+ * draws it in by default.
155
+ *
156
+ * The platform's sheet is translucent — on iOS 26 that is Liquid Glass — and
157
+ * what is behind it shows through. That is right for a sheet laid over
158
+ * content worth glimpsing and wrong for one that is a surface of the app's
159
+ * own, where the app's ground shifting under it reads as a mistake.
160
+ *
161
+ * `true` uses the theme's popover surface, so the sheet matches the rest of
162
+ * the app in both schemes. A string paints that colour exactly.
163
+ *
164
+ * It only reaches the platform's sheet, so it does nothing without `native`.
165
+ * On iOS below 16.4 the sheet keeps its material.
166
+ */
167
+ nativeBackground?: boolean | string;
152
168
  }
153
169
 
154
170
  /**
@@ -158,6 +174,22 @@ export interface BottomSheetProps {
158
174
  */
159
175
  const DETENT_FRACTION = { half: 0.5, full: 0.9 } as const;
160
176
 
177
+ /**
178
+ * How long to treat a dismissal *we* asked for as still in flight.
179
+ *
180
+ * A timer, because there is nothing to wait on. The platform reports a
181
+ * dismissal the reader performed — that report is the binding writing its new
182
+ * value back — but not one we asked for, where the binding already holds the
183
+ * value it would have written and the change is suppressed at the source. So
184
+ * the only sheet whose departure can be observed is the one we did not ask to
185
+ * leave.
186
+ *
187
+ * Comfortably past the system's own sheet transition, since the cost of being
188
+ * late is a present held a little longer than it needed to be, and the cost of
189
+ * being early is the platform dropping it and no sheet at all.
190
+ */
191
+ const NATIVE_DISMISS_MS = 400;
192
+
161
193
  /**
162
194
  * The height the content should at least fill for a given set of detents.
163
195
  *
@@ -191,6 +223,29 @@ export function bottomSheetDetentHeight(
191
223
  return undefined;
192
224
  }
193
225
 
226
+ /**
227
+ * The modifier that paints a native sheet's own surface, for whichever toolkit
228
+ * is drawing it — or nothing, where neither is reachable.
229
+ *
230
+ * A sheet's surface is not the background of anything hosted in it. The
231
+ * grabber's strip at the top and the safe-area inset at the bottom are the
232
+ * sheet's own chrome, and a colour put behind the content stops short of both
233
+ * of them — so the sheet arrives two-tone, and shifts as it moves between
234
+ * detents. These two reach the surface itself.
235
+ *
236
+ * The toolkits do not share a vocabulary, so the question is asked of each in
237
+ * its own terms rather than one answer being sent to both.
238
+ */
239
+ function nativeSheetSurface(color: string): unknown[] | undefined {
240
+ const swiftUI = getSwiftUIModifiers();
241
+ if (swiftUI) return [swiftUI.presentationBackground(color)];
242
+
243
+ const compose = getComposeModifiers();
244
+ if (compose) return [compose.background(color)];
245
+
246
+ return undefined;
247
+ }
248
+
194
249
  /**
195
250
  * Set by the root so Content knows the platform is drawing the sheet, and with
196
251
  * which detents. Null means the styled sheet renders.
@@ -198,6 +253,7 @@ export function bottomSheetDetentHeight(
198
253
  const NativeSheetContext = createContext<{
199
254
  nativeUI: NonNullable<ReturnType<typeof getNativeUI>>;
200
255
  snapPoints: BottomSheetProps['snapPoints'];
256
+ background: BottomSheetProps['nativeBackground'];
201
257
  } | null>(null);
202
258
 
203
259
  /**
@@ -225,6 +281,7 @@ function BottomSheetRoot({
225
281
  defaultOpen = false,
226
282
  native,
227
283
  snapPoints,
284
+ nativeBackground,
228
285
  }: BottomSheetProps) {
229
286
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
230
287
  const isControlled = open !== undefined;
@@ -245,8 +302,8 @@ function BottomSheetRoot({
245
302
 
246
303
  const nativeUI = native ? getNativeUI() : null;
247
304
  const nativeSheet = useMemo(
248
- () => (nativeUI ? { nativeUI, snapPoints } : null),
249
- [nativeUI, snapPoints]
305
+ () => (nativeUI ? { nativeUI, snapPoints, background: nativeBackground } : null),
306
+ [nativeBackground, nativeUI, snapPoints]
250
307
  );
251
308
 
252
309
  return (
@@ -352,6 +409,10 @@ function BottomSheetContent({
352
409
  const insets = useSafeAreaInsets();
353
410
  const translateY = useSharedValue(0);
354
411
  const closeTint = useCSSVariable('--color-muted-foreground');
412
+ // Read unconditionally, used only by the native branch: the platform draws
413
+ // that sheet's container, so a token can only reach it as a colour handed
414
+ // over, never as a class.
415
+ const popoverSurface = useCSSVariable('--color-popover');
355
416
 
356
417
  /*
357
418
  * The scrolling body's gesture, if there is one. It is built here rather
@@ -428,41 +489,86 @@ function BottomSheetContent({
428
489
  * sees a sheet that "took a moment" or never came at all.
429
490
  *
430
491
  * `isPresented` is therefore driven from here rather than straight from
431
- * `open`. A present that arrives during a dismissal is held, and replayed
432
- * from the platform's own `onDismiss`, which is the only signal that says the
433
- * previous sheet has finished going away.
492
+ * `open`: a present that arrives during a dismissal is held, and let through
493
+ * once that dismissal is over.
494
+ *
495
+ * Knowing when it is over is the hard half, because it depends on who asked.
496
+ * A dismissal the reader performed is reported — that report is the platform
497
+ * writing the new value back to us. One we asked for is not: the value is
498
+ * already what the platform would have written, so the change is suppressed
499
+ * at the source and no report is ever sent. A queue that waits for one
500
+ * regardless waits for ever, and every present after the first is held —
501
+ * which is a sheet that opens once and then never again, whether the flag was
502
+ * raised by the reader's swipe or by a Close button inside the sheet.
503
+ *
504
+ * So the two are ended by their own means: the reader's by the report, ours
505
+ * by {@link NATIVE_DISMISS_MS} — and by the report as well, if one turns up,
506
+ * since arriving early is only ever an improvement.
434
507
  */
435
508
  const [nativePresented, setNativePresented] = useState(open);
509
+ const nativeOnScreen = useRef(open);
436
510
  const nativeDismissing = useRef(false);
511
+ const nativeDismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
437
512
  const openRef = useRef(open);
438
513
  openRef.current = open;
439
514
 
515
+ /** The dismissal is over: let a held present through, if one is waiting. */
516
+ const endNativeDismissal = useCallback(() => {
517
+ if (nativeDismissTimer.current !== null) {
518
+ clearTimeout(nativeDismissTimer.current);
519
+ nativeDismissTimer.current = null;
520
+ }
521
+ if (!nativeDismissing.current) return;
522
+ nativeDismissing.current = false;
523
+
524
+ if (openRef.current) {
525
+ nativeOnScreen.current = true;
526
+ setNativePresented(true);
527
+ }
528
+ }, []);
529
+
440
530
  useEffect(() => {
441
531
  if (!nativeSheet) return;
442
532
 
443
533
  if (open) {
444
534
  if (nativeDismissing.current) return;
535
+ nativeOnScreen.current = true;
445
536
  setNativePresented(true);
446
537
  return;
447
538
  }
448
539
 
449
- if (nativePresented) nativeDismissing.current = true;
540
+ // Only a sheet the platform still has on screen can be in the middle of
541
+ // leaving. One the reader already swiped away has reported itself gone.
542
+ if (nativeOnScreen.current) {
543
+ nativeOnScreen.current = false;
544
+ nativeDismissing.current = true;
545
+ if (nativeDismissTimer.current !== null) clearTimeout(nativeDismissTimer.current);
546
+ nativeDismissTimer.current = setTimeout(endNativeDismissal, NATIVE_DISMISS_MS);
547
+ }
450
548
  setNativePresented(false);
451
- }, [nativePresented, nativeSheet, open]);
549
+ }, [endNativeDismissal, nativeSheet, open]);
452
550
 
453
- const onNativeDismiss = useCallback(() => {
454
- // Ours, or the reader's? A dismissal we asked for has to hand the queue
455
- // back; one the reader performed has to be reported as a close.
456
- const wasOurs = nativeDismissing.current;
457
- nativeDismissing.current = false;
551
+ useEffect(
552
+ () => () => {
553
+ if (nativeDismissTimer.current !== null) clearTimeout(nativeDismissTimer.current);
554
+ },
555
+ []
556
+ );
458
557
 
459
- if (wasOurs) {
460
- if (openRef.current) setNativePresented(true);
558
+ const onNativeDismiss = useCallback(() => {
559
+ // The platform reporting that its sheet has gone. Whatever asked for it,
560
+ // there is nothing on screen now.
561
+ nativeOnScreen.current = false;
562
+
563
+ // Ours, or the reader's? A dismissal we asked for hands the queue back
564
+ // ahead of its window; one the reader performed is a close to report.
565
+ if (nativeDismissing.current) {
566
+ endNativeDismissal();
461
567
  return;
462
568
  }
463
569
 
464
570
  if (dismissible) close();
465
- }, [close, dismissible]);
571
+ }, [close, dismissible, endNativeDismissal]);
466
572
 
467
573
  const pan = useMemo(
468
574
  () =>
@@ -571,6 +677,22 @@ function BottomSheetContent({
571
677
  */
572
678
  const snapPoints =
573
679
  nativeSheet.snapPoints ?? (size === 'auto' ? undefined : [size]);
680
+ /*
681
+ * `true` means the theme's surface, and a string means itself. A token
682
+ * that did not resolve is dropped rather than passed on — an unresolved
683
+ * colour reaching the platform is a sheet painted some default, which is
684
+ * further from the material than leaving the material alone.
685
+ */
686
+ const requested = nativeSheet.background;
687
+ const surface =
688
+ requested === true
689
+ ? typeof popoverSurface === 'string'
690
+ ? popoverSurface
691
+ : undefined
692
+ : typeof requested === 'string'
693
+ ? requested
694
+ : undefined;
695
+ const modifiers = surface ? nativeSheetSurface(surface) : undefined;
574
696
  // The platform owns presentation, so this stays mounted and toggles
575
697
  // isPresented rather than unmounting on close.
576
698
  //
@@ -583,6 +705,7 @@ function BottomSheetContent({
583
705
  isPresented={nativePresented}
584
706
  onDismiss={onNativeDismiss}
585
707
  snapPoints={snapPoints}
708
+ modifiers={modifiers}
586
709
  >
587
710
  <RNHostView matchContents>
588
711
  <BottomSheetContext.Provider value={context}>
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Where each character sits on the ring, kept out of the component so it can
3
+ * be tested without rendering one.
4
+ *
5
+ * Every number here reaches a transform, and a transform treats `NaN` and
6
+ * `Infinity` as a frame that never resolves rather than as an error — a ring
7
+ * handed a bad radius should draw nothing, not wedge. So the guards live at
8
+ * the boundary, once, instead of at each use.
9
+ */
10
+
11
+ /** Points from the centre to the baseline the characters sit on. */
12
+ export const DEFAULT_CIRCULAR_TEXT_RADIUS = 90;
13
+
14
+ /** Milliseconds for one full turn. Slow: it is decoration, not a spinner. */
15
+ export const DEFAULT_CIRCULAR_TEXT_DURATION = 20000;
16
+
17
+ /** Degrees of the circle the text is spread across. The whole way round. */
18
+ export const DEFAULT_CIRCULAR_TEXT_SPREAD = 360;
19
+
20
+ export function normalizeRadius(value: number): number {
21
+ if (!Number.isFinite(value)) return DEFAULT_CIRCULAR_TEXT_RADIUS;
22
+ return Math.max(value, 0);
23
+ }
24
+
25
+ export function normalizeDuration(value: number): number {
26
+ if (!Number.isFinite(value)) return DEFAULT_CIRCULAR_TEXT_DURATION;
27
+ return Math.max(value, 0);
28
+ }
29
+
30
+ export function normalizeSpread(value: number): number {
31
+ if (!Number.isFinite(value)) return DEFAULT_CIRCULAR_TEXT_SPREAD;
32
+ // Clamped rather than wrapped: past a full turn the characters would overlap
33
+ // the ones already there, and a spread of -40 is a spread of 40 the other
34
+ // way round, which `reverse` is the prop for.
35
+ return Math.min(Math.abs(value), 360);
36
+ }
37
+
38
+ export interface CircularTextGlyph {
39
+ /** The character itself. */
40
+ character: string;
41
+ /** Its place in the string, which is what makes a stable key. */
42
+ index: number;
43
+ /** Degrees clockwise from the top, before any rotation of the ring. */
44
+ angle: number;
45
+ }
46
+
47
+ /**
48
+ * One entry per character, evenly spaced around the arc.
49
+ *
50
+ * The step is measured between characters rather than across them, and which
51
+ * of the two you divide by is the whole difference between a closed ring and
52
+ * one with a visible seam. A full turn has no last gap — the end of the string
53
+ * is adjacent to its start — so `n` characters make `n` gaps and the step is
54
+ * `spread / n`. Anything less than a full turn has one gap fewer than it has
55
+ * characters, so the step is `spread / (n - 1)` and the text reaches both ends
56
+ * of the arc it was given instead of stopping short of the second one.
57
+ */
58
+ export function circularTextGlyphs(
59
+ text: string,
60
+ spread: number,
61
+ startAngle: number
62
+ ): CircularTextGlyph[] {
63
+ const characters = Array.from(text);
64
+ if (characters.length === 0) return [];
65
+
66
+ const arc = normalizeSpread(spread);
67
+ const from = Number.isFinite(startAngle) ? startAngle : 0;
68
+ const closed = arc >= 360;
69
+
70
+ // One character cannot be spread across anything, and dividing by the zero
71
+ // gaps it has would put it nowhere.
72
+ const step =
73
+ characters.length === 1 ? 0 : arc / (closed ? characters.length : characters.length - 1);
74
+
75
+ return characters.map((character, index) => ({
76
+ character,
77
+ index,
78
+ angle: from + step * index,
79
+ }));
80
+ }
@@ -0,0 +1,217 @@
1
+ import { useEffect, useMemo } from 'react';
2
+ import { View, type ViewProps } from 'react-native';
3
+ import Animated, {
4
+ Easing,
5
+ cancelAnimation,
6
+ useAnimatedStyle,
7
+ useReducedMotion,
8
+ useSharedValue,
9
+ withRepeat,
10
+ withTiming,
11
+ } from 'react-native-reanimated';
12
+ import { tv } from 'tailwind-variants';
13
+ import { Text } from '../../primitives/text';
14
+ import { cn } from '../../utils/cn';
15
+ import {
16
+ DEFAULT_CIRCULAR_TEXT_DURATION,
17
+ DEFAULT_CIRCULAR_TEXT_RADIUS,
18
+ DEFAULT_CIRCULAR_TEXT_SPREAD,
19
+ circularTextGlyphs,
20
+ normalizeDuration,
21
+ normalizeRadius,
22
+ normalizeSpread,
23
+ } from './circular-text-geometry';
24
+
25
+ const circularTextVariants = tv({
26
+ base: 'items-center justify-center',
27
+ });
28
+
29
+ const glyphVariants = tv({
30
+ /*
31
+ * Every character gets a box the size of the ring, laid over the same point,
32
+ * with the character at the top edge of it. Rotating the box about its own
33
+ * centre — which is the ring's centre — is what swings the character round
34
+ * the circle, and it means no per-character arithmetic reaches the transform
35
+ * beyond the one angle.
36
+ *
37
+ * The box's size is set inline rather than by a utility, and that is not
38
+ * decoration: a box that states its own width and height cannot end up with
39
+ * a different one, and one glyph sitting at twice the radius of the rest is
40
+ * exactly what a box of the wrong size looks like.
41
+ */
42
+ base: 'items-center justify-start',
43
+ });
44
+
45
+ /**
46
+ * The glyph size the ring is drawn at unless the caller says otherwise.
47
+ *
48
+ * A step up from the body default. Text bent round a circle is read a letter
49
+ * at a time rather than as a word, and at body size the letters are small
50
+ * enough that the ring reads as a texture instead of as a phrase.
51
+ */
52
+ const GLYPH_TEXT = 'text-lg';
53
+
54
+ export interface CircularTextProps extends Omit<ViewProps, 'children'> {
55
+ /**
56
+ * The text to set around the circle. A string, not elements: each character
57
+ * is placed and turned on its own, so there is nothing for markup inside it
58
+ * to apply to.
59
+ */
60
+ children: string;
61
+ /**
62
+ * Points from the centre of the ring to the outside of the text.
63
+ *
64
+ * It is also half the component's width and height — the ring is a square
65
+ * that measures `radius * 2` on both axes, and the characters hang inside
66
+ * its edge. Nothing is laid out around it, so give the space it needs.
67
+ */
68
+ radius?: number;
69
+ /**
70
+ * Milliseconds for one full turn. Slow by default: it is decoration, and a
71
+ * ring that turns at the speed of a spinner reads as something loading.
72
+ */
73
+ spinDuration?: number;
74
+ /** Turn anticlockwise. */
75
+ reverse?: boolean;
76
+ /**
77
+ * Hold the ring where it is.
78
+ *
79
+ * It stops in place rather than returning to the top, and resumes from
80
+ * there, so a ring paused mid-word is still on that word when it starts
81
+ * again.
82
+ */
83
+ paused?: boolean;
84
+ /**
85
+ * Degrees of the circle the text is spread across. The whole way round by
86
+ * default.
87
+ *
88
+ * A full turn has no last gap, since the end of the string is adjacent to
89
+ * its start. Anything less is an arc with two ends, and the text reaches
90
+ * both of them.
91
+ */
92
+ spread?: number;
93
+ /** Degrees clockwise from the top that the first character sits at. */
94
+ startAngle?: number;
95
+ /** Classes for the ring's own box. */
96
+ className?: string;
97
+ /** Classes for the characters — size, weight, colour, tracking. */
98
+ textClassName?: string;
99
+ }
100
+
101
+ /**
102
+ * Text set around a circle, turning.
103
+ *
104
+ * For a badge, a seal, a mark around a logo: decoration whose job is to be a
105
+ * shape first and a sentence second. The characters are placed one at a time
106
+ * and each is turned to sit square on the curve, so the ring closes and the
107
+ * text at the bottom is upside down — which is what makes it read as a
108
+ * circle rather than as a sentence bent into one.
109
+ *
110
+ * The centre is left empty and nothing is laid out inside it. Put a logo
111
+ * there by stacking the two, rather than by passing it as a child.
112
+ *
113
+ * The turn runs entirely on the UI thread, and under the platform's
114
+ * reduce-motion setting the ring is drawn once and held still. Not a slower
115
+ * turn — none. The shape carries the whole meaning; the rotation is the part
116
+ * that setting exists to remove.
117
+ */
118
+ export function CircularText({
119
+ children,
120
+ radius = DEFAULT_CIRCULAR_TEXT_RADIUS,
121
+ spinDuration = DEFAULT_CIRCULAR_TEXT_DURATION,
122
+ reverse = false,
123
+ paused = false,
124
+ spread = DEFAULT_CIRCULAR_TEXT_SPREAD,
125
+ startAngle = 0,
126
+ className,
127
+ textClassName,
128
+ style,
129
+ ...props
130
+ }: CircularTextProps) {
131
+ const reducedMotion = useReducedMotion();
132
+ const rotation = useSharedValue(0);
133
+
134
+ const size = normalizeRadius(radius) * 2;
135
+ const duration = normalizeDuration(spinDuration);
136
+ const arc = normalizeSpread(spread);
137
+
138
+ const glyphs = useMemo(
139
+ () => circularTextGlyphs(children, arc, startAngle),
140
+ [children, arc, startAngle]
141
+ );
142
+
143
+ // A duration of zero is a timing that never advances, so it is a held ring
144
+ // rather than an infinitely fast one.
145
+ const turning = !paused && !reducedMotion && duration > 0 && glyphs.length > 0;
146
+
147
+ useEffect(() => {
148
+ if (!turning) {
149
+ // Leaves the value where it reached. Zeroing here would send a ring
150
+ // paused mid-word back to the top, and the pause would read as a reset.
151
+ cancelAnimation(rotation);
152
+ return undefined;
153
+ }
154
+
155
+ /*
156
+ * From wherever it already is, one turn, forever.
157
+ *
158
+ * The repeat restarts each cycle at the value it began on, and the state
159
+ * at `from + 360` is the state at `from`, so the seam between cycles is
160
+ * arithmetic rather than something to see. Taking the remainder first
161
+ * keeps the number from growing without bound over a long session.
162
+ */
163
+ const from = rotation.value % 360;
164
+ const to = reverse ? from - 360 : from + 360;
165
+ rotation.value = from;
166
+ rotation.value = withRepeat(
167
+ withTiming(to, { duration, easing: Easing.linear }),
168
+ -1,
169
+ false
170
+ );
171
+
172
+ return () => cancelAnimation(rotation);
173
+ }, [rotation, turning, duration, reverse]);
174
+
175
+ const ringStyle = useAnimatedStyle(() => ({
176
+ transform: [{ rotate: `${rotation.value}deg` }],
177
+ }));
178
+
179
+ return (
180
+ <View
181
+ accessible
182
+ accessibilityRole="text"
183
+ accessibilityLabel={children}
184
+ className={cn(circularTextVariants(), className)}
185
+ style={[{ width: size, height: size }, style]}
186
+ {...props}
187
+ >
188
+ {/* Hidden from assistive technology as a group: the label above already
189
+ says what it reads, and a ring announced character by character is a
190
+ string spelled out one letter at a time. */}
191
+ <Animated.View
192
+ accessibilityElementsHidden
193
+ importantForAccessibility="no-hide-descendants"
194
+ style={[{ width: size, height: size }, ringStyle]}
195
+ >
196
+ {glyphs.map((glyph) => (
197
+ <View
198
+ key={`${glyph.character}-${glyph.index}`}
199
+ className={glyphVariants()}
200
+ style={{
201
+ position: 'absolute',
202
+ left: 0,
203
+ top: 0,
204
+ width: size,
205
+ height: size,
206
+ transform: [{ rotate: `${glyph.angle}deg` }],
207
+ }}
208
+ >
209
+ <Text className={cn(GLYPH_TEXT, textClassName)}>{glyph.character}</Text>
210
+ </View>
211
+ ))}
212
+ </Animated.View>
213
+ </View>
214
+ );
215
+ }
216
+
217
+ CircularText.displayName = 'CircularText';
@@ -25,6 +25,15 @@ export const DOT_LIT_RADIUS = 1.9;
25
25
  /** How visible the resting grid is, under everything. */
26
26
  export const DOT_REST_OPACITY = 0.16;
27
27
 
28
+ /**
29
+ * How visible the lit dots are, together.
30
+ *
31
+ * "Together" is the operative word: they are drawn as two layers fading into
32
+ * one another, and this is what the pair composites to, not what either one
33
+ * carries. {@link crossfadeAlphas} is what holds that true.
34
+ */
35
+ export const LIT_OPACITY = 0.9;
36
+
28
37
  /** How many still frames one loop is drawn as. */
29
38
  export const FRAMES = 24;
30
39
 
@@ -118,11 +127,20 @@ export function influenceAt(distance: number, radius: number): number {
118
127
  /**
119
128
  * Where the light is at a phase of the loop, and how wide it reaches.
120
129
  *
121
- * `drift` wanders around the middle — two periods that do not divide into each
122
- * other, so the path never closes into a loop the eye can learn, and small
123
- * amplitudes because a light that reaches the corners stops reading as one
124
- * source. `pulse` is a ring leaving the centre. `scan` crosses as a band, which
125
- * is the same maths with the light infinitely tall.
130
+ * `drift` wanders around the middle on a figure-eight: one horizontal pass to
131
+ * two vertical, so the path arrives back where it started and the loop has no
132
+ * seam in it. The amplitudes are small because a light that reaches the corners
133
+ * stops reading as one source.
134
+ *
135
+ * The two frequencies used not to divide into each other, on the reasoning that
136
+ * a path which never closes is one the eye cannot learn. It closes anyway —
137
+ * every animation here restarts at the end of its period — so all that bought
138
+ * was a jump of four normal steps, once a pass. A figure-eight is not a shape
139
+ * anybody follows over four seconds of soft light on a dot grid.
140
+ *
141
+ * `pulse` is a ring leaving the centre, and has expanded past the last dot
142
+ * before it restarts, so it fades out rather than snapping back. `scan` crosses
143
+ * as a band, which is the same maths with the light infinitely tall.
126
144
  */
127
145
  function lightAt(
128
146
  animation: DotFieldAnimation,
@@ -157,7 +175,7 @@ function lightAt(
157
175
 
158
176
  return {
159
177
  x: width / 2 + Math.sin(turn) * width * 0.26,
160
- y: height / 2 + Math.cos(turn * 1.37) * height * 0.22,
178
+ y: height / 2 + Math.cos(turn * 2) * height * 0.22,
161
179
  radius: short * 0.42,
162
180
  ring: 0,
163
181
  };
@@ -169,19 +187,25 @@ function lightAt(
169
187
  * Only the dots the light actually reaches are in it — the rest are already
170
188
  * drawn by the resting grid underneath, so this is a fraction of the field
171
189
  * rather than all of it.
190
+ *
191
+ * `points` is the same grid {@link anchors} would build, passed in by a caller
192
+ * that is about to ask for every frame of a loop. Building it here instead cost
193
+ * a fresh array of several hundred pairs per frame, to arrive at the identical
194
+ * grid twenty-four times over.
172
195
  */
173
196
  export function litPath(
174
197
  width: number,
175
198
  height: number,
176
199
  phase: number,
177
- animation: DotFieldAnimation = 'drift'
200
+ animation: DotFieldAnimation = 'drift',
201
+ points?: [number, number][]
178
202
  ): string {
179
203
  if (width <= 0 || height <= 0) return '';
180
204
 
181
205
  const light = lightAt(animation, phase, width, height);
182
206
  let path = '';
183
207
 
184
- for (const [x, y] of anchors(width, height)) {
208
+ for (const [x, y] of points ?? anchors(width, height)) {
185
209
  const deltaX = x - light.x;
186
210
  const deltaY = Number.isNaN(light.y) ? 0 : y - light.y;
187
211
  const distance = Math.abs(
@@ -206,8 +230,9 @@ export function litFrames(
206
230
  height: number,
207
231
  animation: DotFieldAnimation = 'drift'
208
232
  ): string[] {
233
+ const points = width > 0 && height > 0 ? anchors(width, height) : [];
209
234
  return Array.from({ length: FRAMES }, (_unused, index) =>
210
- litPath(width, height, index / FRAMES, animation)
235
+ litPath(width, height, index / FRAMES, animation, points)
211
236
  );
212
237
  }
213
238
 
@@ -224,3 +249,34 @@ export function frameAt(time: number, animation: DotFieldAnimation): number {
224
249
  const phase = (time % PERIOD[animation]) / PERIOD[animation];
225
250
  return Math.min(FRAMES - 1, Math.floor(phase * FRAMES));
226
251
  }
252
+
253
+ /**
254
+ * Where a moment falls in the loop, as a frame index with its fraction kept.
255
+ *
256
+ * {@link frameAt} rounds this down, and rounding it down is what made the field
257
+ * a flipbook: twenty-four pictures spread over the period, which on the slowest
258
+ * animation is a new one every 175ms however fast the screen refreshes. The
259
+ * fraction is what the two layers cross-fade on, so the light moves at the rate
260
+ * the display can draw rather than the rate the frames were built at.
261
+ */
262
+ export function framePhase(time: number, animation: DotFieldAnimation): number {
263
+ 'worklet';
264
+ return ((time % PERIOD[animation]) / PERIOD[animation]) * FRAMES;
265
+ }
266
+
267
+ /**
268
+ * What the outgoing and incoming layers are worth, a fraction `t` of the way
269
+ * from one frame to the next.
270
+ *
271
+ * Not `[1 - t, t]`. The layers are drawn over one another, so a dot lit in both
272
+ * of them composites to `1 - (1-a)(1-b)`, and two half-strength copies of it
273
+ * come to 0.70 rather than 0.90 — the field dips a fifth in the middle of every
274
+ * step, which six steps a second turns into a flicker. So only the outgoing
275
+ * layer ramps, and the incoming one is solved for: whatever leaves the pair at
276
+ * {@link LIT_OPACITY} the whole way across.
277
+ */
278
+ export function crossfadeAlphas(t: number): [number, number] {
279
+ 'worklet';
280
+ const out = LIT_OPACITY * (1 - t);
281
+ return [out, 1 - (1 - LIT_OPACITY) / (1 - out)];
282
+ }