panelui-native 0.14.0 → 0.19.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 (86) hide show
  1. package/README.md +2 -0
  2. package/lib/module/components/bottom-sheet/index.js +38 -10
  3. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  4. package/lib/module/components/checkbox/index.js +24 -11
  5. package/lib/module/components/checkbox/index.js.map +1 -1
  6. package/lib/module/components/direction/index.js +86 -0
  7. package/lib/module/components/direction/index.js.map +1 -0
  8. package/lib/module/components/field/index.js +337 -0
  9. package/lib/module/components/field/index.js.map +1 -0
  10. package/lib/module/components/flow/flow-paths.js +378 -0
  11. package/lib/module/components/flow/flow-paths.js.map +1 -0
  12. package/lib/module/components/flow/index.js +1598 -0
  13. package/lib/module/components/flow/index.js.map +1 -0
  14. package/lib/module/components/form/index.js +77 -0
  15. package/lib/module/components/form/index.js.map +1 -0
  16. package/lib/module/components/form/use-field.js +50 -0
  17. package/lib/module/components/form/use-field.js.map +1 -0
  18. package/lib/module/components/form/use-form.js +217 -0
  19. package/lib/module/components/form/use-form.js.map +1 -0
  20. package/lib/module/components/frame/index.js +124 -22
  21. package/lib/module/components/frame/index.js.map +1 -1
  22. package/lib/module/components/message/index.js +16 -1
  23. package/lib/module/components/message/index.js.map +1 -1
  24. package/lib/module/components/otp-input/index.js +303 -0
  25. package/lib/module/components/otp-input/index.js.map +1 -0
  26. package/lib/module/components/signature/index.js +500 -0
  27. package/lib/module/components/signature/index.js.map +1 -0
  28. package/lib/module/components/soundwave/index.js +813 -0
  29. package/lib/module/components/soundwave/index.js.map +1 -0
  30. package/lib/module/components/typography/index.js +196 -5
  31. package/lib/module/components/typography/index.js.map +1 -1
  32. package/lib/module/icons/index.js +287 -0
  33. package/lib/module/icons/index.js.map +1 -1
  34. package/lib/module/index.js +8 -1
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/typescript/src/components/bottom-sheet/index.d.ts +15 -1
  37. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  38. package/lib/typescript/src/components/checkbox/index.d.ts +8 -0
  39. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/direction/index.d.ts +64 -0
  41. package/lib/typescript/src/components/direction/index.d.ts.map +1 -0
  42. package/lib/typescript/src/components/field/index.d.ts +94 -0
  43. package/lib/typescript/src/components/field/index.d.ts.map +1 -0
  44. package/lib/typescript/src/components/flow/flow-paths.d.ts +89 -0
  45. package/lib/typescript/src/components/flow/flow-paths.d.ts.map +1 -0
  46. package/lib/typescript/src/components/flow/index.d.ts +310 -0
  47. package/lib/typescript/src/components/flow/index.d.ts.map +1 -0
  48. package/lib/typescript/src/components/form/index.d.ts +64 -0
  49. package/lib/typescript/src/components/form/index.d.ts.map +1 -0
  50. package/lib/typescript/src/components/form/use-field.d.ts +16 -0
  51. package/lib/typescript/src/components/form/use-field.d.ts.map +1 -0
  52. package/lib/typescript/src/components/form/use-form.d.ts +37 -0
  53. package/lib/typescript/src/components/form/use-form.d.ts.map +1 -0
  54. package/lib/typescript/src/components/frame/index.d.ts +39 -2
  55. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  56. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  57. package/lib/typescript/src/components/otp-input/index.d.ts +141 -0
  58. package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -0
  59. package/lib/typescript/src/components/signature/index.d.ts +254 -0
  60. package/lib/typescript/src/components/signature/index.d.ts.map +1 -0
  61. package/lib/typescript/src/components/soundwave/index.d.ts +91 -0
  62. package/lib/typescript/src/components/soundwave/index.d.ts.map +1 -0
  63. package/lib/typescript/src/components/typography/index.d.ts +154 -1
  64. package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
  65. package/lib/typescript/src/icons/index.d.ts +23 -0
  66. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  67. package/lib/typescript/src/index.d.ts +9 -2
  68. package/lib/typescript/src/index.d.ts.map +1 -1
  69. package/package.json +9 -1
  70. package/src/components/bottom-sheet/index.tsx +50 -12
  71. package/src/components/checkbox/index.tsx +40 -12
  72. package/src/components/direction/index.tsx +90 -0
  73. package/src/components/field/index.tsx +329 -0
  74. package/src/components/flow/flow-paths.ts +348 -0
  75. package/src/components/flow/index.tsx +1924 -0
  76. package/src/components/form/index.tsx +85 -0
  77. package/src/components/form/use-field.ts +65 -0
  78. package/src/components/form/use-form.ts +252 -0
  79. package/src/components/frame/index.tsx +162 -14
  80. package/src/components/message/index.tsx +17 -1
  81. package/src/components/otp-input/index.tsx +396 -0
  82. package/src/components/signature/index.tsx +681 -0
  83. package/src/components/soundwave/index.tsx +1029 -0
  84. package/src/components/typography/index.tsx +231 -10
  85. package/src/icons/index.tsx +195 -0
  86. package/src/index.ts +87 -0
@@ -0,0 +1,1029 @@
1
+ /**
2
+ * Soundwave — what a voice looks like while an app is listening to it.
3
+ *
4
+ * ```tsx
5
+ * <Soundwave variant="pills" state="listening" level={level} />
6
+ * ```
7
+ *
8
+ * Four looks, because a voice screen needs different ones in different places:
9
+ * `pills` for the few big capsules over a microphone button, `bars` for a
10
+ * metering strip in a transcript, `line` for a travelling wave while the
11
+ * assistant talks back, and `ambient` for a glow that takes the whole screen.
12
+ *
13
+ * ## It draws a level; it does not record one
14
+ *
15
+ * Nothing here touches the microphone. The app owns the recorder — the
16
+ * permission prompt, the session, the platform quirks — and hands over a number
17
+ * between 0 and 1, which keeps this component free of an audio dependency and
18
+ * usable against a real meter, a synthesised one, or a remote peer's.
19
+ *
20
+ * With no `level` at all it animates its own plausible motion for the current
21
+ * `state`, so a screen can be built and reviewed before any audio exists.
22
+ *
23
+ * ## Levels arrive faster than React should re-render
24
+ *
25
+ * A recorder reports every 30–60ms. Setting that in state is dozens of renders
26
+ * a second for a number that only moves pixels, so `level` also accepts a
27
+ * `SharedValue`: write metering straight into it and nothing above this
28
+ * component ever re-renders. A plain number works too and is smoothed the same
29
+ * way — it is the right choice when the level comes from something slow, like a
30
+ * server-side speaking flag.
31
+ *
32
+ * Either way the value is smoothed with a fast attack and a slow release, which
33
+ * is what makes a meter read as a meter: it snaps up on a syllable and falls
34
+ * back gently, instead of chattering around every sample.
35
+ *
36
+ * ## How each one is drawn
37
+ *
38
+ * `bars` and `line` are a *single* animated SVG path — one vertical segment per
39
+ * bar with a round cap, or one polyline — so forty bars cost one animated prop
40
+ * a frame rather than forty animated views, and the capsule ends come free from
41
+ * the stroke cap. `pills` is a handful of views, where that machinery would be
42
+ * more expensive than the thing it saves. Everything runs in a worklet on the
43
+ * UI thread; React renders on a resize and otherwise not at all.
44
+ */
45
+ import { useEffect, useId, useState, type ReactElement, type ReactNode } from 'react';
46
+ import {
47
+ StyleSheet,
48
+ View,
49
+ type LayoutChangeEvent,
50
+ type ViewProps,
51
+ } from 'react-native';
52
+ import { LinearGradient } from 'expo-linear-gradient';
53
+ import Animated, {
54
+ useAnimatedProps,
55
+ useAnimatedStyle,
56
+ useFrameCallback,
57
+ useReducedMotion,
58
+ useSharedValue,
59
+ type SharedValue,
60
+ } from 'react-native-reanimated';
61
+ import Svg, { Defs, LinearGradient as SvgGradient, Path, Stop } from 'react-native-svg';
62
+ import { useCSSVariable } from 'uniwind';
63
+ import { useIconColor } from '../../icons';
64
+ import { cn } from '../../utils/cn';
65
+
66
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
67
+
68
+ export type SoundwaveVariant = 'pills' | 'bars' | 'line' | 'ambient';
69
+
70
+ export type SoundwaveState = 'idle' | 'listening' | 'thinking' | 'speaking';
71
+
72
+ /** What each state is doing, for anyone who cannot see it. */
73
+ const STATE_LABEL: Record<SoundwaveState, string> = {
74
+ idle: 'Idle',
75
+ listening: 'Listening',
76
+ thinking: 'Thinking',
77
+ speaking: 'Speaking',
78
+ };
79
+
80
+ /**
81
+ * How fast the smoothed level rises and falls, per second.
82
+ *
83
+ * Wildly asymmetric on purpose. A meter that rises and falls at the same rate
84
+ * either lags the syllable that caused it or flickers on every sample; snapping
85
+ * up and easing down tracks speech the way an ear expects it to.
86
+ */
87
+ const ATTACK = 16;
88
+ const RELEASE = 5;
89
+
90
+ /** Never quite silent: a wave flat at zero reads as broken rather than quiet. */
91
+ const FLOOR = 0.04;
92
+
93
+ /* -------------------------------------------------------------------------- */
94
+ /* Worklet maths */
95
+ /* -------------------------------------------------------------------------- */
96
+
97
+ function clamp01(value: number): number {
98
+ 'worklet';
99
+ return value < 0 ? 0 : value > 1 ? 1 : value;
100
+ }
101
+
102
+ /** Deterministic hash in `[0, 1)`. Stable across frames and across mounts. */
103
+ function hashD(a: number, b: number): number {
104
+ 'worklet';
105
+ const h = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453;
106
+ return h - Math.floor(h);
107
+ }
108
+
109
+ /** One decimal place. Path strings are rebuilt every frame; every digit costs. */
110
+ function q(value: number): number {
111
+ 'worklet';
112
+ return Math.round(value * 10) / 10;
113
+ }
114
+
115
+ /**
116
+ * The motion a state runs on its own, with no level supplied.
117
+ *
118
+ * Layered at unrelated tempi so it never repeats on a beat — a single sine
119
+ * reads as a pulse, and a pulse reads as a progress indicator rather than as a
120
+ * voice.
121
+ */
122
+ function idleEnergy(state: SoundwaveState, t: number): number {
123
+ 'worklet';
124
+ if (state === 'listening') {
125
+ return clamp01(
126
+ 0.34 +
127
+ 0.22 * Math.sin(t * 2.3) +
128
+ 0.16 * Math.sin(t * 3.9 + 1.3) +
129
+ 0.1 * Math.sin(t * 7.1 + 0.6)
130
+ );
131
+ }
132
+ if (state === 'speaking') {
133
+ return clamp01(
134
+ 0.46 +
135
+ 0.27 * Math.sin(t * 3.1) +
136
+ 0.18 * Math.sin(t * 5.7 + 0.8) +
137
+ 0.09 * Math.sin(t * 9.3)
138
+ );
139
+ }
140
+ if (state === 'thinking') {
141
+ return clamp01(0.18 + 0.1 * Math.sin(t * 1.6) + 0.05 * Math.sin(t * 2.9 + 2.1));
142
+ }
143
+ return clamp01(0.07 + 0.05 * Math.sin(t * 1.1));
144
+ }
145
+
146
+ /* -------------------------------------------------------------------------- */
147
+ /* The clock every variant shares */
148
+ /* -------------------------------------------------------------------------- */
149
+
150
+ interface Engine {
151
+ /** Seconds since mount, scaled by `speed`. */
152
+ clock: SharedValue<number>;
153
+ /** Smoothed level, 0–1. What every variant actually draws. */
154
+ energy: SharedValue<number>;
155
+ /** Newest first is at the end. Only filled in `scrolling` mode. */
156
+ samples: SharedValue<number[]>;
157
+ }
158
+
159
+ const isShared = (value: unknown): value is SharedValue<number> =>
160
+ typeof value === 'object' && value !== null && 'value' in value;
161
+
162
+ /**
163
+ * One shared value to read, whether the caller passed a number or their own.
164
+ *
165
+ * A plain number is copied in; a `SharedValue` is handed straight back, which
166
+ * is the whole reason for accepting one — metering written into it never
167
+ * re-renders anything.
168
+ */
169
+ function useNumberSource(value: number | SharedValue<number> | undefined): SharedValue<number> {
170
+ const own = useSharedValue(0);
171
+ const external = isShared(value) ? value : null;
172
+
173
+ useEffect(() => {
174
+ if (typeof value === 'number') own.value = value;
175
+ }, [value, own]);
176
+
177
+ return external ?? own;
178
+ }
179
+
180
+ /** How often `scrolling` mode takes a sample, in seconds. */
181
+ const SAMPLE_INTERVAL = 1 / 24;
182
+
183
+ interface EngineOptions {
184
+ level?: number | SharedValue<number>;
185
+ state: SoundwaveState;
186
+ sensitivity: number;
187
+ speed: number;
188
+ paused: boolean;
189
+ /** Sample count to keep for `scrolling` mode. Zero keeps none. */
190
+ history: number;
191
+ }
192
+
193
+ function useEngine({
194
+ level,
195
+ state,
196
+ sensitivity,
197
+ speed,
198
+ paused,
199
+ history,
200
+ }: EngineOptions): Engine {
201
+ const reducedMotion = useReducedMotion();
202
+ const clock = useSharedValue(0);
203
+ const energy = useSharedValue(0);
204
+ const samples = useSharedValue<number[]>([]);
205
+ const nextSample = useSharedValue(0);
206
+
207
+ const source = useNumberSource(level);
208
+ const driven = level !== undefined;
209
+
210
+ const running = !paused && !reducedMotion;
211
+
212
+ const frame = useFrameCallback((info) => {
213
+ 'worklet';
214
+ // Elapsed time is accumulated rather than read off the total, so `speed`
215
+ // can change mid-animation without the wave jumping to wherever the new
216
+ // rate would have put it. A dropped frame is clamped rather than honoured —
217
+ // a 300ms hitch played back at full rate is a lurch.
218
+ const delta = Math.min(info.timeSincePreviousFrame ?? 16, 48) / 1000;
219
+ clock.value += delta * speed;
220
+
221
+ const target = driven
222
+ ? clamp01(source.value * sensitivity)
223
+ : idleEnergy(state, clock.value);
224
+
225
+ const rate = target > energy.value ? ATTACK : RELEASE;
226
+ energy.value += (target - energy.value) * Math.min(1, delta * rate);
227
+
228
+ if (history > 0 && clock.value >= nextSample.value) {
229
+ nextSample.value = clock.value + SAMPLE_INTERVAL;
230
+ // A little per-sample texture, so a held note is a band of varying bars
231
+ // rather than a solid block — real speech never gives two equal samples.
232
+ const jitter = 0.82 + 0.36 * hashD(clock.value, 3.3);
233
+ const next = samples.value.slice(samples.value.length >= history ? 1 : 0);
234
+ next.push(clamp01(energy.value * jitter));
235
+ samples.value = next;
236
+ }
237
+ }, false);
238
+
239
+ const { setActive } = frame;
240
+ useEffect(() => {
241
+ setActive(running);
242
+ return () => setActive(false);
243
+ }, [running, setActive]);
244
+
245
+ // Stopped is not empty: reduced motion and `paused` both get a representative
246
+ // frame rather than a flat line, which is the difference between "not
247
+ // animating" and "broken".
248
+ useEffect(() => {
249
+ if (running) return;
250
+ const still = driven ? clamp01(source.value * sensitivity) : 0.42;
251
+ energy.value = still;
252
+ if (history > 0) {
253
+ samples.value = Array.from({ length: history }, (_unused, index) =>
254
+ clamp01(still * (0.45 + 0.55 * Math.abs(Math.sin(index * 0.7))))
255
+ );
256
+ }
257
+ }, [running, driven, sensitivity, history, source, energy, samples]);
258
+
259
+ return { clock, energy, samples };
260
+ }
261
+
262
+ /* -------------------------------------------------------------------------- */
263
+ /* pills */
264
+ /* -------------------------------------------------------------------------- */
265
+
266
+ /**
267
+ * One capsule.
268
+ *
269
+ * A component rather than a loop of `useAnimatedStyle` in the parent, because
270
+ * the count is a prop — hooks in a loop is a rule waiting to be broken by
271
+ * whoever changes the default.
272
+ */
273
+ function Pill({
274
+ index,
275
+ count,
276
+ engine,
277
+ width,
278
+ minHeight,
279
+ maxHeight,
280
+ color,
281
+ }: {
282
+ index: number;
283
+ count: number;
284
+ engine: Engine;
285
+ width: number;
286
+ minHeight: number;
287
+ maxHeight: number;
288
+ color: string;
289
+ }) {
290
+ const style = useAnimatedStyle(() => {
291
+ /*
292
+ * Each capsule runs at its own tempo and phase, hashed off its index. Give
293
+ * them one tempo and they rise and fall as a block, which reads as a
294
+ * loading bar; detune them and the group reads as something responding to a
295
+ * voice, even though they all follow the same level.
296
+ */
297
+ const rate = 2.2 + hashD(index, 1.7) * 2.6;
298
+ const phase = hashD(index, 5.1) * Math.PI * 2;
299
+ const wobble = 0.55 + 0.45 * Math.sin(engine.clock.value * rate + phase);
300
+
301
+ // The middle capsules lead. A voice meter with a flat profile looks like a
302
+ // level indicator; a slight hump looks like a mouth.
303
+ const centre = count > 1 ? 1 - Math.abs((index / (count - 1)) * 2 - 1) : 1;
304
+ const shape = 0.68 + 0.32 * centre;
305
+
306
+ const value = clamp01(Math.max(FLOOR, engine.energy.value) * wobble * shape);
307
+ return { height: minHeight + (maxHeight - minHeight) * value };
308
+ });
309
+
310
+ return (
311
+ <Animated.View
312
+ style={[
313
+ { width, borderRadius: width / 2, backgroundColor: color },
314
+ style,
315
+ ]}
316
+ />
317
+ );
318
+ }
319
+
320
+ function PillsWave({
321
+ engine,
322
+ count,
323
+ barWidth,
324
+ barGap,
325
+ height,
326
+ color,
327
+ }: {
328
+ engine: Engine;
329
+ count: number;
330
+ barWidth: number;
331
+ barGap: number;
332
+ height: number;
333
+ color: string;
334
+ }) {
335
+ // Never shorter than a circle: a capsule squashed past its own width stops
336
+ // being a capsule.
337
+ const minHeight = barWidth * 1.35;
338
+
339
+ return (
340
+ <View
341
+ style={{ height, columnGap: barGap }}
342
+ className="flex-row items-center justify-center"
343
+ >
344
+ {Array.from({ length: count }, (_unused, index) => (
345
+ <Pill
346
+ key={index}
347
+ index={index}
348
+ count={count}
349
+ engine={engine}
350
+ width={barWidth}
351
+ minHeight={minHeight}
352
+ maxHeight={height}
353
+ color={color}
354
+ />
355
+ ))}
356
+ </View>
357
+ );
358
+ }
359
+
360
+ /* -------------------------------------------------------------------------- */
361
+ /* bars */
362
+ /* -------------------------------------------------------------------------- */
363
+
364
+ /**
365
+ * One bar's height, 0–1, from whichever source is driving this wave.
366
+ *
367
+ * Pulled out of the drawing loop because two paths walk the same bars — the
368
+ * played part of a recording and the rest of it — and a bar has to come out
369
+ * identical in both or the split shows as a step.
370
+ */
371
+ function barValue(
372
+ i: number,
373
+ count: number,
374
+ scrolling: boolean,
375
+ supplied: number[],
376
+ history: number[],
377
+ clock: number,
378
+ energy: number
379
+ ): number {
380
+ 'worklet';
381
+ if (scrolling) {
382
+ // The buffer fills from empty, so the newest sample sits at the trailing
383
+ // edge from the first frame rather than the wave sliding in from nowhere.
384
+ const offset = i - (count - history.length);
385
+ return offset >= 0 ? (history[offset] ?? 0) : 0;
386
+ }
387
+ if (supplied.length) {
388
+ // Real bands, resampled to the bar count — an analysis rarely hands back
389
+ // exactly as many numbers as there are bars, and a recorded waveform never
390
+ // does.
391
+ return supplied[Math.floor((i / count) * supplied.length)] ?? 0;
392
+ }
393
+ const rate = 2.1 + hashD(i, 1.3) * 2.9;
394
+ const phase = hashD(i, 4.7) * Math.PI * 2;
395
+ const wobble = 0.5 + 0.5 * Math.sin(clock * rate + phase);
396
+ const centre = count > 1 ? Math.sin((Math.PI * (i + 0.5)) / count) : 1;
397
+ return energy * wobble * (0.45 + 0.55 * centre);
398
+ }
399
+
400
+ /** Ink left on the part of a recording that has not played yet. */
401
+ const UNPLAYED_OPACITY = 0.3;
402
+
403
+ /**
404
+ * The stroke a wave is painted with, when it is not one flat colour.
405
+ *
406
+ * One definition covers both jobs, because they are the same object: a run of
407
+ * colour stops across the width, optionally pinned to transparent at each end.
408
+ * Doing the edge fade this way rather than as an overlay in the background
409
+ * colour is what lets a wave sit on a card, a bubble or a photograph — an
410
+ * overlay only disappears against the one surface it was told about.
411
+ */
412
+ function WaveGradient({
413
+ id,
414
+ colors,
415
+ fade,
416
+ edge,
417
+ }: {
418
+ id: string;
419
+ colors: readonly string[];
420
+ fade: boolean;
421
+ edge: number;
422
+ }) {
423
+ const stops = colors.length ? colors : ['#000000'];
424
+ const span = fade ? 1 - edge * 2 : 1;
425
+ const from = fade ? edge : 0;
426
+
427
+ const nodes: ReactElement[] = [];
428
+ if (fade) nodes.push(<Stop key="fade-start" offset="0" stopColor={stops[0]} stopOpacity="0" />);
429
+ stops.forEach((stop, index) => {
430
+ const at = stops.length > 1 ? from + (index / (stops.length - 1)) * span : from;
431
+ nodes.push(
432
+ <Stop key={index} offset={String(at)} stopColor={stop} stopOpacity="1" />
433
+ );
434
+ });
435
+ if (fade) {
436
+ nodes.push(
437
+ <Stop key="fade-end" offset="1" stopColor={stops[stops.length - 1]} stopOpacity="0" />
438
+ );
439
+ }
440
+
441
+ return (
442
+ <Defs>
443
+ {/* Built as a list rather than inline, because the stop count varies with
444
+ the palette and with whether the ends are faded. */}
445
+ <SvgGradient id={id} x1="0" y1="0" x2="1" y2="0">
446
+ {nodes}
447
+ </SvgGradient>
448
+ </Defs>
449
+ );
450
+ }
451
+
452
+ function BarsWave({
453
+ engine,
454
+ bands,
455
+ progress,
456
+ hasProgress,
457
+ count,
458
+ barWidth,
459
+ height,
460
+ width,
461
+ centered,
462
+ scrolling,
463
+ stroke,
464
+ trackStroke,
465
+ defs,
466
+ }: {
467
+ engine: Engine;
468
+ bands: SharedValue<number[]>;
469
+ progress: SharedValue<number>;
470
+ hasProgress: boolean;
471
+ count: number;
472
+ barWidth: number;
473
+ height: number;
474
+ width: number;
475
+ centered: boolean;
476
+ scrolling: boolean;
477
+ stroke: string;
478
+ /** Explicit colour for the unplayed part, if the caller set one. */
479
+ trackStroke: string | null;
480
+ defs: ReactNode;
481
+ }) {
482
+ /*
483
+ * Two paths, split at the playhead: the bars behind it keep full ink, the
484
+ * ones ahead are dimmed. It is two animated props a frame rather than one,
485
+ * and still nothing per bar — which is the point of drawing bars as a stroked
486
+ * path in the first place.
487
+ *
488
+ * With no `progress` the first path takes everything and the second is empty,
489
+ * so a live meter and a voice note run the same code.
490
+ */
491
+ const drawSegment = (played: boolean) => () => {
492
+ 'worklet';
493
+ const step = count > 1 ? (width - barWidth) / (count - 1) : 0;
494
+ const span = height - barWidth;
495
+ const supplied = bands.value;
496
+ const history = engine.samples.value;
497
+ const head = hasProgress ? clamp01(progress.value) * count : count;
498
+ let d = '';
499
+
500
+ for (let i = 0; i < count; i++) {
501
+ // The playhead cuts between bars, not through one: a bar is either
502
+ // played or it is not, which is what makes the fill land on a beat.
503
+ const isPlayed = i + 0.5 <= head;
504
+ if (isPlayed !== played) continue;
505
+
506
+ const value = barValue(
507
+ i,
508
+ count,
509
+ scrolling,
510
+ supplied,
511
+ history,
512
+ engine.clock.value,
513
+ engine.energy.value
514
+ );
515
+ const length = span * clamp01(Math.max(FLOOR, value));
516
+ const x = q(barWidth / 2 + i * step);
517
+ if (centered) {
518
+ const half = length / 2;
519
+ d += `M${x} ${q(height / 2 - half)}L${x} ${q(height / 2 + half)}`;
520
+ } else {
521
+ d += `M${x} ${q(height - barWidth / 2)}L${x} ${q(height - barWidth / 2 - length)}`;
522
+ }
523
+ }
524
+
525
+ return { d };
526
+ };
527
+
528
+ const playedProps = useAnimatedProps(drawSegment(true));
529
+ const restProps = useAnimatedProps(drawSegment(false));
530
+
531
+ return (
532
+ <Svg width={width} height={height}>
533
+ {defs}
534
+ <AnimatedPath
535
+ animatedProps={restProps}
536
+ stroke={trackStroke ?? stroke}
537
+ // A track colour of its own is drawn as given; without one, the unplayed
538
+ // part is the same ink turned down.
539
+ strokeOpacity={trackStroke ? 1 : hasProgress ? UNPLAYED_OPACITY : 1}
540
+ strokeWidth={barWidth}
541
+ strokeLinecap="round"
542
+ fill="none"
543
+ />
544
+ <AnimatedPath
545
+ animatedProps={playedProps}
546
+ stroke={stroke}
547
+ strokeWidth={barWidth}
548
+ strokeLinecap="round"
549
+ fill="none"
550
+ />
551
+ </Svg>
552
+ );
553
+ }
554
+
555
+ /* -------------------------------------------------------------------------- */
556
+ /* line */
557
+ /* -------------------------------------------------------------------------- */
558
+
559
+ /** Points along the wave. Enough to read as a curve, few enough to rebuild. */
560
+ const LINE_POINTS = 56;
561
+
562
+ function LineWave({
563
+ engine,
564
+ width,
565
+ height,
566
+ strokeWidth,
567
+ stroke,
568
+ defs,
569
+ }: {
570
+ engine: Engine;
571
+ width: number;
572
+ height: number;
573
+ strokeWidth: number;
574
+ stroke: string;
575
+ defs: ReactNode;
576
+ }) {
577
+ const animatedProps = useAnimatedProps(() => {
578
+ const mid = height / 2;
579
+ const amplitude = (height / 2 - strokeWidth) * clamp01(Math.max(FLOOR, engine.energy.value));
580
+ const t = engine.clock.value;
581
+ let d = '';
582
+
583
+ for (let i = 0; i <= LINE_POINTS; i++) {
584
+ const f = i / LINE_POINTS;
585
+ const x = f * width;
586
+ /*
587
+ * Three waves at unrelated wavelengths, tapered to nothing at both ends.
588
+ * The taper is what makes it a wave and not a rope: the line leaves and
589
+ * meets the edges flat, so there is no hard stop where it is cut off.
590
+ */
591
+ const taper = Math.sin(Math.PI * f);
592
+ const y =
593
+ mid -
594
+ amplitude *
595
+ taper *
596
+ (0.62 * Math.sin(f * 12.6 - t * 3.1) +
597
+ 0.26 * Math.sin(f * 21.4 - t * 4.7 + 1.1) +
598
+ 0.12 * Math.sin(f * 33.2 - t * 2.3));
599
+ d += `${i === 0 ? 'M' : 'L'}${q(x)} ${q(y)}`;
600
+ }
601
+
602
+ return { d };
603
+ });
604
+
605
+ return (
606
+ <Svg width={width} height={height}>
607
+ {defs}
608
+ <AnimatedPath
609
+ animatedProps={animatedProps}
610
+ stroke={stroke}
611
+ strokeWidth={strokeWidth}
612
+ strokeLinecap="round"
613
+ strokeLinejoin="round"
614
+ fill="none"
615
+ />
616
+ </Svg>
617
+ );
618
+ }
619
+
620
+ /* -------------------------------------------------------------------------- */
621
+ /* ambient */
622
+ /* -------------------------------------------------------------------------- */
623
+
624
+ /** A theme token name — `--color-info` — rather than a literal colour. */
625
+ const isToken = (value: string | undefined): value is string =>
626
+ typeof value === 'string' && value.startsWith('--');
627
+
628
+ /** `useCSSVariable` can hand back a non-string when a variable is unset. */
629
+ const asString = (value: unknown): string | undefined =>
630
+ typeof value === 'string' && value.length > 0 ? value : undefined;
631
+
632
+ /** Turns any resolved colour into the same colour at a given alpha. */
633
+ function withAlpha(color: string, alpha: number): string {
634
+ if (color.startsWith('#')) {
635
+ const hex = color.slice(1);
636
+ const full =
637
+ hex.length === 3
638
+ ? hex
639
+ .split('')
640
+ .map((c) => c + c)
641
+ .join('')
642
+ : hex.slice(0, 6);
643
+ const r = parseInt(full.slice(0, 2), 16);
644
+ const g = parseInt(full.slice(2, 4), 16);
645
+ const b = parseInt(full.slice(4, 6), 16);
646
+ if (Number.isNaN(r + g + b)) return color;
647
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
648
+ }
649
+
650
+ const channels = color.match(/rgba?\(([^)]+)\)/)?.[1];
651
+ if (channels) {
652
+ const [r, g, b] = channels.split(',').map((part) => part.trim());
653
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
654
+ }
655
+
656
+ return color;
657
+ }
658
+
659
+ /**
660
+ * The glow that fills a screen: a bloom rising off the bottom edge and a rim of
661
+ * light around the rest.
662
+ *
663
+ * Both breathe on the level rather than only fading in and out — a glow that
664
+ * changes opacity alone reads as a screen dimming, where one that also grows
665
+ * reads as something in the room getting louder.
666
+ */
667
+ function AmbientWave({
668
+ engine,
669
+ color,
670
+ bloomColors,
671
+ radius,
672
+ }: {
673
+ engine: Engine;
674
+ color: string;
675
+ /** Bottom to top. Two or more colours make the bloom a gradient of its own. */
676
+ bloomColors: readonly string[] | null;
677
+ radius: number;
678
+ }) {
679
+ const bloom = useAnimatedStyle(() => {
680
+ const e = clamp01(Math.max(FLOOR, engine.energy.value));
681
+ const breath = 0.94 + 0.06 * Math.sin(engine.clock.value * 1.7);
682
+ return {
683
+ opacity: 0.25 + 0.75 * e,
684
+ transform: [{ scaleY: (0.72 + 0.4 * e) * breath }],
685
+ };
686
+ });
687
+
688
+ const rim = useAnimatedStyle(() => {
689
+ const e = clamp01(Math.max(FLOOR, engine.energy.value));
690
+ return { opacity: 0.12 + 0.5 * e };
691
+ });
692
+
693
+ /*
694
+ * The bloom fades in from nothing at the top whatever it is made of, so a
695
+ * supplied palette is ramped rather than used flat — a gradient that starts
696
+ * at full strength has a visible edge across the middle of the screen.
697
+ */
698
+ const ramp: string[] = bloomColors?.length
699
+ ? [
700
+ withAlpha(bloomColors[0]!, 0),
701
+ ...bloomColors.map((stop, index) =>
702
+ withAlpha(stop, 0.16 + (0.34 * index) / Math.max(1, bloomColors.length - 1))
703
+ ),
704
+ ]
705
+ : [withAlpha(color, 0), withAlpha(color, 0.16), withAlpha(color, 0.5)];
706
+
707
+ return (
708
+ <View
709
+ pointerEvents="none"
710
+ style={[StyleSheet.absoluteFill, { borderRadius: radius, overflow: 'hidden' }]}
711
+ >
712
+ {/* The rim, as three rings of falling alpha. Three flat borders read as a
713
+ soft edge where one crisp border reads as a frame around the screen. */}
714
+ <Animated.View style={[StyleSheet.absoluteFill, rim]}>
715
+ {[0, 1, 2].map((ring) => (
716
+ <View
717
+ key={ring}
718
+ style={[
719
+ StyleSheet.absoluteFill,
720
+ {
721
+ margin: ring * 3,
722
+ borderRadius: Math.max(0, radius - ring * 3),
723
+ borderWidth: 3,
724
+ borderColor: withAlpha(color, 0.4 - ring * 0.13),
725
+ },
726
+ ]}
727
+ />
728
+ ))}
729
+ </Animated.View>
730
+
731
+ <Animated.View
732
+ style={[
733
+ { position: 'absolute', left: 0, right: 0, bottom: 0, height: '55%' },
734
+ // Anchored to the bottom so growing pushes the bloom up the screen
735
+ // rather than pulling it off the edge.
736
+ { transformOrigin: 'bottom' },
737
+ bloom,
738
+ ]}
739
+ >
740
+ <LinearGradient
741
+ colors={ramp as [string, string, ...string[]]}
742
+ start={{ x: 0.5, y: 0 }}
743
+ end={{ x: 0.5, y: 1 }}
744
+ style={StyleSheet.absoluteFill}
745
+ />
746
+ </Animated.View>
747
+ </View>
748
+ );
749
+ }
750
+
751
+ /* -------------------------------------------------------------------------- */
752
+ /* Component */
753
+ /* -------------------------------------------------------------------------- */
754
+
755
+ /** Per-variant geometry, so a bare `<Soundwave variant="bars" />` looks right. */
756
+ const DEFAULTS: Record<
757
+ SoundwaveVariant,
758
+ { bars: number; barWidth: number; barGap: number; height: number }
759
+ > = {
760
+ pills: { bars: 4, barWidth: 28, barGap: 10, height: 96 },
761
+ bars: { bars: 40, barWidth: 3, barGap: 3, height: 56 },
762
+ line: { bars: 0, barWidth: 3, barGap: 0, height: 72 },
763
+ ambient: { bars: 0, barWidth: 0, barGap: 0, height: 0 },
764
+ };
765
+
766
+ export interface SoundwaveProps extends Omit<ViewProps, 'children'> {
767
+ className?: string;
768
+ /**
769
+ * Which look to draw: `pills` for a few capsules over a microphone button,
770
+ * `bars` for a metering strip, `line` for a travelling wave, `ambient` for a
771
+ * glow that fills its parent.
772
+ */
773
+ variant?: SoundwaveVariant;
774
+ /**
775
+ * What the app is doing. With no `level` supplied this picks the motion the
776
+ * wave runs on its own; it always sets what a screen reader announces.
777
+ */
778
+ state?: SoundwaveState;
779
+ /**
780
+ * Input level, 0–1, from your own recorder's metering. Pass a `SharedValue`
781
+ * to keep updates off the JS thread entirely. Omit it and the wave animates
782
+ * plausible motion for the current `state`.
783
+ */
784
+ level?: number | SharedValue<number>;
785
+ /**
786
+ * Per-band levels, 0–1 each, for `bars` in `static` mode when the app has a
787
+ * real frequency analysis — or the stored shape of a finished recording.
788
+ * Resampled to the bar count, and drawn still rather than animated.
789
+ */
790
+ levels?: number[];
791
+ /**
792
+ * Playback position through a recording, 0–1. Bars behind it keep full ink
793
+ * and the rest are dimmed, which is the voice-note progress bar. `bars` only,
794
+ * and it goes with `levels` — a recording has a fixed shape to play through.
795
+ */
796
+ progress?: number | SharedValue<number>;
797
+ /** How many capsules (`pills`) or bars (`bars`) to draw. */
798
+ bars?: number;
799
+ /** Capsule width, or bar stroke width. Also the stroke width of `line`. */
800
+ barWidth?: number;
801
+ /** Gap between capsules. `bars` spaces itself evenly across the width. */
802
+ barGap?: number;
803
+ /** Drawing height. `ambient` ignores it and fills its parent. */
804
+ height?: number;
805
+ /**
806
+ * `static` gives every bar a band of the current level; `scrolling` keeps a
807
+ * history that slides across, newest at the trailing edge. `bars` only.
808
+ */
809
+ mode?: 'static' | 'scrolling';
810
+ /** Grow bars from the middle out rather than up from the baseline. */
811
+ centered?: boolean;
812
+ /** Fade the wave out at both ends, so it does not stop at a hard edge. */
813
+ fadeEdges?: boolean;
814
+ /** Multiplier on the incoming level, applied before it is clamped to 1. */
815
+ sensitivity?: number;
816
+ /** Multiplier on the wave's own tempo, including how fast history scrolls. */
817
+ speed?: number;
818
+ /**
819
+ * Ink. Takes a colour — `#f97316`, `rgba(…)` — or a **theme token name**,
820
+ * `color="--color-info"`, which resolves against the active theme and follows
821
+ * it into dark mode.
822
+ *
823
+ * Left unset, a wave inside a surface that publishes a foreground (a chat
824
+ * bubble, a button) is drawn in that foreground, and anywhere else in
825
+ * `--color-foreground` — or `--color-info` for `ambient`.
826
+ */
827
+ color?: string;
828
+ /**
829
+ * Colour across the wave instead of one flat ink: two or more colours spread
830
+ * left to right, or ramped up from the bottom edge for `ambient`. Literal
831
+ * colours only — for a themed one, resolve the tokens with `useCSSVariable`
832
+ * and pass the result.
833
+ */
834
+ gradient?: readonly string[];
835
+ /**
836
+ * Colour of the part of a recording that has not played yet. Defaults to the
837
+ * ink at low opacity, which is right for most surfaces; set it when you want
838
+ * the track to read as its own thing. `bars` with `progress`.
839
+ */
840
+ trackColor?: string;
841
+ /** Freeze on the current frame. */
842
+ paused?: boolean;
843
+ /** Corner radius `ambient` traces. Match it to the screen it sits on. */
844
+ radius?: number;
845
+ /** Overrides the per-state default announced to screen readers. */
846
+ accessibilityLabel?: string;
847
+ }
848
+
849
+ export function Soundwave({
850
+ className,
851
+ variant = 'pills',
852
+ state = 'listening',
853
+ level,
854
+ levels,
855
+ progress,
856
+ bars,
857
+ barWidth,
858
+ barGap,
859
+ height,
860
+ mode = 'static',
861
+ centered = true,
862
+ fadeEdges,
863
+ sensitivity = 1,
864
+ speed = 1,
865
+ color,
866
+ gradient,
867
+ trackColor,
868
+ paused = false,
869
+ radius = 44,
870
+ accessibilityLabel,
871
+ style,
872
+ ...props
873
+ }: SoundwaveProps) {
874
+ const defaults = DEFAULTS[variant];
875
+ const count = bars ?? defaults.bars;
876
+ const stroke = barWidth ?? defaults.barWidth;
877
+ const gap = barGap ?? defaults.barGap;
878
+ const box = height ?? defaults.height;
879
+ const scrolling = variant === 'bars' && mode === 'scrolling';
880
+ const fade = fadeEdges ?? (variant === 'line' || scrolling);
881
+
882
+ const foreground = useCSSVariable('--color-foreground');
883
+ const info = useCSSVariable('--color-info');
884
+ /*
885
+ * A token name is resolved here rather than by the caller, so `color` and
886
+ * `trackColor` can be written the way the rest of the library is themed —
887
+ * `--color-info` instead of a hex someone has to keep in step with the theme.
888
+ */
889
+ const colorToken = useCSSVariable(isToken(color) ? color : '--color-foreground');
890
+ const trackToken = useCSSVariable(isToken(trackColor) ? trackColor : '--color-foreground');
891
+ const themed = variant === 'ambient' ? info : foreground;
892
+ /*
893
+ * A wave inside a coloured surface has to be drawn in that surface's
894
+ * foreground, not the page's: a sent chat bubble is painted in the primary
895
+ * colour, and in most themes the primary colour *is* the text colour — so a
896
+ * wave that resolved `--color-foreground` for itself would be invisible on
897
+ * the one screen it is most likely to appear on. Surfaces already publish
898
+ * their readable foreground for icons; a wave is ink for the same reason.
899
+ *
900
+ * `ambient` opts out: it is a glow behind a whole screen, not ink on a
901
+ * surface, and it wants its own colour.
902
+ */
903
+ const inherited = useIconColor();
904
+ const surface = variant === 'ambient' ? undefined : inherited;
905
+ const explicit = isToken(color) ? asString(colorToken) : color;
906
+ const ink = explicit ?? surface ?? asString(themed) ?? '#0a0a0a';
907
+ const track = isToken(trackColor) ? asString(trackToken) : trackColor;
908
+
909
+ /*
910
+ * A recording drawn from `levels` has no motion in it: every bar comes from
911
+ * the stored shape, so the clock advances a frame nobody reads. A transcript
912
+ * can hold twenty voice notes, and twenty idle frame callbacks is twenty too
913
+ * many — so a supplied waveform stops the engine, and `progress` still
914
+ * redraws it, because that is a shared value of its own.
915
+ */
916
+ const stillWaveform = variant === 'bars' && !scrolling && (levels?.length ?? 0) > 0;
917
+
918
+ const engine = useEngine({
919
+ level,
920
+ state,
921
+ sensitivity,
922
+ speed,
923
+ paused: paused || stillWaveform,
924
+ history: scrolling ? count : 0,
925
+ });
926
+
927
+ // Supplied bands are copied into a shared value so the drawing worklet has a
928
+ // single place to read from, whichever way the level arrived.
929
+ const bands = useSharedValue<number[]>([]);
930
+ useEffect(() => {
931
+ bands.value = levels ?? [];
932
+ }, [levels, bands]);
933
+
934
+ const playhead = useNumberSource(progress);
935
+
936
+ // `bars` and `line` are drawn to the width they are given, which is only
937
+ // known after layout — a metering strip is nearly always as wide as its row.
938
+ const [measured, setMeasured] = useState(0);
939
+ const onLayout = (event: LayoutChangeEvent) => {
940
+ const next = Math.round(event.nativeEvent.layout.width);
941
+ if (next !== measured) setMeasured(next);
942
+ };
943
+
944
+ // Two Defs in one tree cannot share an id, and a screen can hold more than
945
+ // one wave.
946
+ const gradientId = `panelui-soundwave-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
947
+
948
+ /*
949
+ * A gradient and an edge fade are the same object — colour stops across the
950
+ * width — so one definition serves both, and a wave that needs neither is
951
+ * painted with a flat colour and no `Defs` at all.
952
+ */
953
+ const ramp = gradient?.length ? gradient : null;
954
+ const needsDefs = fade || (ramp !== null && ramp.length > 1);
955
+ const paint = needsDefs ? `url(#${gradientId})` : ink;
956
+ const defs = needsDefs ? (
957
+ <WaveGradient
958
+ id={gradientId}
959
+ colors={ramp ?? [ink]}
960
+ fade={fade}
961
+ edge={variant === 'line' ? 0.18 : 0.12}
962
+ />
963
+ ) : null;
964
+
965
+ let content: ReactNode = null;
966
+ if (variant === 'pills') {
967
+ content = (
968
+ <PillsWave
969
+ engine={engine}
970
+ count={count}
971
+ barWidth={stroke}
972
+ barGap={gap}
973
+ height={box}
974
+ color={ink}
975
+ />
976
+ );
977
+ } else if (variant === 'ambient') {
978
+ content = (
979
+ <AmbientWave engine={engine} color={ink} bloomColors={ramp} radius={radius} />
980
+ );
981
+ } else if (measured > 0) {
982
+ content =
983
+ variant === 'bars' ? (
984
+ <BarsWave
985
+ engine={engine}
986
+ bands={bands}
987
+ progress={playhead}
988
+ hasProgress={progress !== undefined}
989
+ count={count}
990
+ barWidth={stroke}
991
+ height={box}
992
+ width={measured}
993
+ centered={centered}
994
+ scrolling={scrolling}
995
+ stroke={paint}
996
+ trackStroke={track ?? null}
997
+ defs={defs}
998
+ />
999
+ ) : (
1000
+ <LineWave
1001
+ engine={engine}
1002
+ width={measured}
1003
+ height={box}
1004
+ strokeWidth={stroke}
1005
+ stroke={paint}
1006
+ defs={defs}
1007
+ />
1008
+ );
1009
+ }
1010
+
1011
+ return (
1012
+ <View
1013
+ {...props}
1014
+ onLayout={variant === 'bars' || variant === 'line' ? onLayout : undefined}
1015
+ pointerEvents={variant === 'ambient' ? 'none' : props.pointerEvents}
1016
+ accessibilityRole="image"
1017
+ accessibilityLabel={accessibilityLabel ?? STATE_LABEL[state]}
1018
+ className={cn(
1019
+ variant === 'ambient' ? 'absolute inset-0' : 'w-full justify-center',
1020
+ className
1021
+ )}
1022
+ style={[variant === 'ambient' ? null : { height: box }, style]}
1023
+ >
1024
+ {content}
1025
+ </View>
1026
+ );
1027
+ }
1028
+
1029
+ Soundwave.displayName = 'Soundwave';