@juspay/svelte-ui-components 4.25.0 → 4.26.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.
@@ -0,0 +1,400 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import {
4
+ AUDIO_ATTACK_MS,
5
+ AUDIO_RELEASE_MS,
6
+ LEVEL_REPORT_EPSILON,
7
+ followEnvelope,
8
+ generateOrbParticles,
9
+ mulberry32,
10
+ orbDepthScale,
11
+ parseCssColor,
12
+ resolveExplicitSeed,
13
+ resolveMotionScale,
14
+ resolveMotionSpeed,
15
+ rmsFromTimeDomain,
16
+ rotateOrbPoint,
17
+ type OrbParticle
18
+ } from './orbMath';
19
+ import type { Rgb } from '../types';
20
+ import { prefersReducedMotion } from '../utils';
21
+ import type { VoiceOrbProperties } from './properties';
22
+
23
+ let {
24
+ variant = 'idle',
25
+ speedMultiplier = 1,
26
+ seed,
27
+ height = 400,
28
+ particleCount = 1000,
29
+ radius = 140,
30
+ interactive = true,
31
+ repelRadius = 200,
32
+ analyser,
33
+ sensitivity = 1,
34
+ persistKey,
35
+ classes,
36
+ testId,
37
+ onfirstframe,
38
+ onlevel
39
+ }: VoiceOrbProperties = $props();
40
+
41
+ const FOCAL_LENGTH = 600;
42
+ const BASE_ROTATION_X = 0.0021;
43
+ const BASE_ROTATION_Y = 0.0014;
44
+ /*
45
+ * Last resort only, and deliberately a mid-grey that is visible on any
46
+ * ground. It is reached when neither the custom property nor the inherited
47
+ * colour normalises, which a browser has to work at to produce. The real
48
+ * default is the inherited text colour -- see `particleRgb`.
49
+ */
50
+ const FALLBACK_PARTICLE_RGB: Rgb = { r: 128, g: 128, b: 128 };
51
+
52
+ let canvas: HTMLCanvasElement | null = $state(null);
53
+ let container: HTMLDivElement | null = $state(null);
54
+
55
+ const pointer: { x: number | null; y: number | null } = { x: null, y: null };
56
+ const orb = { width: 0, height: 0, angleX: 0, angleY: 0 };
57
+
58
+ let listeningElapsedMs = 0;
59
+ /* Explicitly over ArrayBuffer: `getByteTimeDomainData` will not accept the
60
+ ArrayBufferLike default that a bare `Uint8Array` annotation now means. */
61
+ let audioBuffer: Uint8Array<ArrayBuffer> | null = null;
62
+ let audioLevel = 0;
63
+ let reportedLevel = 0;
64
+ let resolvedColorInput = '';
65
+ let resolvedRgb: Rgb = FALLBACK_PARTICLE_RGB;
66
+ let frame: number | null = null;
67
+ let previousTime: number | null = null;
68
+ let announcedFirstFrame = false;
69
+
70
+ /*
71
+ * Resolved once per instance rather than inside `resolveSeed` itself: an
72
+ * unseeded orb should only ever get a new random pattern from an actual new
73
+ * mount, never from `particleCount`/`radius` changing underneath it (see
74
+ * `particles` below, and finding 2 in the review this fixed).
75
+ */
76
+ let unseededSeed = Math.floor(Math.random() * 0xffffffff) >>> 0;
77
+
78
+ const resolveSeed = (): number => resolveExplicitSeed(seed) ?? unseededSeed;
79
+
80
+ /*
81
+ * $derived rather than assigned once in `onMount`: `particleCount`, `radius`
82
+ * and `seed` are reactive props, and generating the sphere only at mount
83
+ * meant changing any of them afterwards silently kept the original geometry.
84
+ * `unseededSeed` above is what keeps an unseeded orb from also reshuffling
85
+ * on a change to `particleCount`/`radius` alone.
86
+ */
87
+ const particles: readonly OrbParticle[] = $derived.by(() =>
88
+ generateOrbParticles(particleCount, radius, mulberry32(resolveSeed()))
89
+ );
90
+
91
+ /*
92
+ * Storage is namespaced by the caller's key and every read is guarded: a
93
+ * browser with storage disabled throws on access rather than returning null,
94
+ * and a persisted value can be anything by the time it is read back.
95
+ */
96
+ const readPersisted = (suffix: string): number | null => {
97
+ if (typeof persistKey !== 'string' || persistKey.length === 0) {
98
+ return null;
99
+ }
100
+ try {
101
+ const raw = localStorage.getItem(`${persistKey}:${suffix}`);
102
+ if (raw === null) {
103
+ return null;
104
+ }
105
+ const parsed = Number.parseFloat(raw);
106
+ return Number.isFinite(parsed) ? parsed : null;
107
+ } catch {
108
+ return null;
109
+ }
110
+ };
111
+
112
+ const writePersisted = (): void => {
113
+ if (typeof persistKey !== 'string' || persistKey.length === 0) {
114
+ return;
115
+ }
116
+ try {
117
+ localStorage.setItem(`${persistKey}:angleX`, String(orb.angleX));
118
+ localStorage.setItem(`${persistKey}:angleY`, String(orb.angleY));
119
+ localStorage.setItem(`${persistKey}:listeningMs`, String(listeningElapsedMs));
120
+ } catch {
121
+ /* Storage full or blocked: rotation resumes from zero, which is cosmetic. */
122
+ }
123
+ };
124
+
125
+ const resize = (): void => {
126
+ if (canvas === null || container === null) {
127
+ return;
128
+ }
129
+ orb.width = container.clientWidth;
130
+ orb.height = Number.isFinite(height) && height > 0 ? height : 400;
131
+ canvas.width = orb.width;
132
+ canvas.height = orb.height;
133
+ };
134
+
135
+ /*
136
+ * Assigning an unparseable colour to `fillStyle` is a no-op by spec, so the
137
+ * candidate is tried against two different sentinels: agreeing reads mean the
138
+ * browser accepted it, differing reads mean it fell through. Reading it back
139
+ * yields a normalised `#rrggbb`, which is how `oklch()`, `color(display-p3 ...)`
140
+ * and named colours reach the particle maths without this component
141
+ * implementing a CSS colour parser.
142
+ */
143
+ const normalizeColor = (context: CanvasRenderingContext2D, value: string): string | null => {
144
+ if (value.length === 0) {
145
+ return null;
146
+ }
147
+ const previous = context.fillStyle;
148
+ context.fillStyle = '#000000';
149
+ context.fillStyle = value;
150
+ const onBlack = context.fillStyle;
151
+ context.fillStyle = '#ffffff';
152
+ context.fillStyle = value;
153
+ const onWhite = context.fillStyle;
154
+ context.fillStyle = previous;
155
+ return typeof onBlack === 'string' && onBlack === onWhite ? onBlack : null;
156
+ };
157
+
158
+ /**
159
+ * The current smoothed audio level, or null when the orb is not being driven
160
+ * by audio -- no analyser, or not listening -- in which case the caller falls
161
+ * back to the sine-wave envelope.
162
+ *
163
+ * Wrapped because a consumer closing their `AudioContext` while the orb is
164
+ * still mounted makes the read throw, and an exception inside the rAF
165
+ * callback kills the loop for good: the orb would freeze rather than degrade.
166
+ */
167
+ const audioEnvelope = (deltaMs: number): number | null => {
168
+ if (!analyser || variant !== 'listening') {
169
+ audioLevel = 0;
170
+ return null;
171
+ }
172
+ try {
173
+ const size = analyser.fftSize;
174
+ if (audioBuffer === null || audioBuffer.length !== size) {
175
+ audioBuffer = new Uint8Array(size);
176
+ }
177
+ analyser.getByteTimeDomainData(audioBuffer);
178
+ const gain = Number.isFinite(sensitivity) && sensitivity > 0 ? sensitivity : 1;
179
+ const target = rmsFromTimeDomain(audioBuffer) * gain;
180
+ audioLevel = followEnvelope(audioLevel, target, deltaMs, AUDIO_ATTACK_MS, AUDIO_RELEASE_MS);
181
+ return audioLevel;
182
+ } catch {
183
+ return null;
184
+ }
185
+ };
186
+
187
+ /*
188
+ * The default is the inherited text colour, so the orb is legible on whatever
189
+ * ground a consumer puts it on and follows their theme without being told.
190
+ * A fixed default cannot: the first revision of this component shipped a
191
+ * light grey that was invisible on a light page.
192
+ *
193
+ * Resolved every frame rather than once at mount, so a theme switch reaches a
194
+ * running orb. The cache key is the raw strings, so the parse only repeats
195
+ * when the page actually changes -- the `getComputedStyle` read is a colour
196
+ * lookup, which needs no layout.
197
+ */
198
+ const particleRgb = (context: CanvasRenderingContext2D): Rgb => {
199
+ if (canvas === null) {
200
+ return resolvedRgb;
201
+ }
202
+ const styles = getComputedStyle(canvas);
203
+ const declared = styles.getPropertyValue('--sui-orb-particle-color').trim();
204
+ const inherited = styles.color.trim();
205
+ const input = `${declared}|${inherited}`;
206
+ if (input === resolvedColorInput) {
207
+ return resolvedRgb;
208
+ }
209
+
210
+ resolvedColorInput = input;
211
+ const normalized = normalizeColor(context, declared) ?? normalizeColor(context, inherited);
212
+ resolvedRgb = (normalized === null ? null : parseCssColor(normalized)) ?? FALLBACK_PARTICLE_RGB;
213
+ return resolvedRgb;
214
+ };
215
+
216
+ const draw = (time: number): void => {
217
+ if (canvas === null) {
218
+ return;
219
+ }
220
+ const context = canvas.getContext('2d');
221
+ if (context === null) {
222
+ return;
223
+ }
224
+
225
+ const delta = previousTime === null ? 16 : Math.min(64, time - previousTime);
226
+ previousTime = time;
227
+ if (variant === 'listening') {
228
+ listeningElapsedMs += delta;
229
+ }
230
+
231
+ const level = audioEnvelope(delta);
232
+ const reportable = level ?? 0;
233
+ if (Math.abs(reportable - reportedLevel) >= LEVEL_REPORT_EPSILON) {
234
+ reportedLevel = reportable;
235
+ onlevel?.(reportable);
236
+ }
237
+ const speed = resolveMotionSpeed(
238
+ speedMultiplier,
239
+ variant,
240
+ listeningElapsedMs,
241
+ level,
242
+ reducedMotion
243
+ );
244
+ const normalizedDelta = delta / 16;
245
+ orb.angleX += BASE_ROTATION_X * speed * normalizedDelta;
246
+ orb.angleY += BASE_ROTATION_Y * speed * normalizedDelta;
247
+
248
+ const rgb = particleRgb(context);
249
+
250
+ const centreX = orb.width / 2;
251
+ const centreY = orb.height / 2;
252
+ const scale = resolveMotionScale(variant, listeningElapsedMs, level, reducedMotion);
253
+
254
+ context.clearRect(0, 0, orb.width, orb.height);
255
+ context.save();
256
+ context.translate(centreX, centreY);
257
+ context.scale(scale, scale);
258
+ context.translate(-centreX, -centreY);
259
+
260
+ for (const particle of particles) {
261
+ const rotated = rotateOrbPoint(
262
+ { x: particle.baseX, y: particle.baseY, z: particle.baseZ },
263
+ orb.angleX,
264
+ orb.angleY
265
+ );
266
+ const depth = orbDepthScale(rotated.z, FOCAL_LENGTH);
267
+ let x = centreX + rotated.x * depth;
268
+ let y = centreY + rotated.y * depth;
269
+
270
+ if (interactive && pointer.x !== null && pointer.y !== null) {
271
+ const dx = pointer.x - x;
272
+ const dy = pointer.y - y;
273
+ const distance = Math.sqrt(dx * dx + dy * dy);
274
+ if (distance < repelRadius && distance > 0) {
275
+ const force = (repelRadius - distance) / repelRadius;
276
+ x -= (dx / distance) * force * 20;
277
+ y -= (dy / distance) * force * 20;
278
+ }
279
+ }
280
+
281
+ const gradient = context.createLinearGradient(centreX, centreY, x, y);
282
+ gradient.addColorStop(0, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0)`);
283
+ gradient.addColorStop(0.5, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${depth * 0.25})`);
284
+ gradient.addColorStop(1, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${depth * 0.35})`);
285
+
286
+ context.beginPath();
287
+ context.moveTo(centreX, centreY);
288
+ context.lineTo(x, y);
289
+ context.strokeStyle = gradient;
290
+ context.lineWidth = 0.3;
291
+ context.stroke();
292
+
293
+ context.beginPath();
294
+ context.arc(x, y, particle.size * depth, 0, Math.PI * 2);
295
+ context.fillStyle = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${depth * 0.8})`;
296
+ context.fill();
297
+ }
298
+
299
+ context.restore();
300
+
301
+ if (!announcedFirstFrame) {
302
+ announcedFirstFrame = true;
303
+ onfirstframe?.();
304
+ }
305
+
306
+ frame = requestAnimationFrame(draw);
307
+ };
308
+
309
+ /*
310
+ * `matchMedia` builds a fresh `MediaQueryList` on every call and `draw` runs
311
+ * on every animation frame, so reading the preference inside `draw` scaled
312
+ * that work with frame rate. It is read once and kept current by the query's
313
+ * own `change` event instead; `draw` and the two motion resolvers below see
314
+ * one consistent value for the whole frame either way.
315
+ */
316
+ let reducedMotion = prefersReducedMotion();
317
+
318
+ const trackPointer = (event: PointerEvent): void => {
319
+ if (canvas === null || !interactive) {
320
+ return;
321
+ }
322
+ const rect = canvas.getBoundingClientRect();
323
+ pointer.x = event.clientX - rect.left;
324
+ pointer.y = event.clientY - rect.top;
325
+ };
326
+
327
+ const releasePointer = (): void => {
328
+ pointer.x = null;
329
+ pointer.y = null;
330
+ };
331
+
332
+ onMount(() => {
333
+ orb.angleX = readPersisted('angleX') ?? 0;
334
+ orb.angleY = readPersisted('angleY') ?? 0;
335
+ listeningElapsedMs = readPersisted('listeningMs') ?? 0;
336
+
337
+ resize();
338
+
339
+ const observer =
340
+ typeof ResizeObserver === 'function' ? new ResizeObserver(() => resize()) : null;
341
+ if (observer !== null && container !== null) {
342
+ observer.observe(container);
343
+ }
344
+
345
+ const motionQuery =
346
+ typeof window.matchMedia === 'function'
347
+ ? window.matchMedia('(prefers-reduced-motion: reduce)')
348
+ : null;
349
+ const onMotionChange = (event: MediaQueryListEvent): void => {
350
+ reducedMotion = event.matches;
351
+ };
352
+ if (motionQuery !== null) {
353
+ reducedMotion = motionQuery.matches;
354
+ motionQuery.addEventListener('change', onMotionChange);
355
+ }
356
+
357
+ frame = requestAnimationFrame(draw);
358
+
359
+ return () => {
360
+ if (frame !== null) {
361
+ cancelAnimationFrame(frame);
362
+ frame = null;
363
+ }
364
+ motionQuery?.removeEventListener('change', onMotionChange);
365
+ observer?.disconnect();
366
+ writePersisted();
367
+ };
368
+ });
369
+ </script>
370
+
371
+ <div
372
+ bind:this={container}
373
+ class="voice-orb {classes ?? ''}"
374
+ data-pw={typeof testId === 'string' ? testId : null}
375
+ testID={typeof testId === 'string' ? testId : null}
376
+ >
377
+ <canvas
378
+ bind:this={canvas}
379
+ class="voice-orb-canvas"
380
+ style:height={`${Number.isFinite(height) && height > 0 ? height : 400}px`}
381
+ onpointermove={trackPointer}
382
+ onpointerleave={releasePointer}
383
+ onpointercancel={releasePointer}
384
+ aria-hidden="true"
385
+ ></canvas>
386
+ </div>
387
+
388
+ <style>
389
+ .voice-orb {
390
+ width: 100%;
391
+ overflow: visible;
392
+ }
393
+
394
+ .voice-orb-canvas {
395
+ width: 100%;
396
+ display: block;
397
+ /* Pointer repulsion needs the move events a default touch gesture would eat. */
398
+ touch-action: none;
399
+ }
400
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { VoiceOrbProperties } from './properties';
2
+ declare const VoiceOrb: import("svelte").Component<VoiceOrbProperties, {}, "">;
3
+ type VoiceOrb = ReturnType<typeof VoiceOrb>;
4
+ export default VoiceOrb;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The deterministic half of `VoiceOrb`, extracted so it can be tested.
3
+ *
4
+ * This repo's vitest suite runs no canvas — `getContext` is unimplemented in
5
+ * jsdom — so anything left inside the component is unreachable by a test. The
6
+ * geometry, the seeding and the listening envelope are all pure, so they live
7
+ * here and the component keeps only the drawing. Same split as
8
+ * `Pill/pillTone.ts` and `Table/normalizeColumns.ts`.
9
+ */
10
+ import type { Rgb } from '../types';
11
+ import type { VoiceOrbVariant } from './properties';
12
+ export type OrbParticle = {
13
+ baseX: number;
14
+ baseY: number;
15
+ baseZ: number;
16
+ size: number;
17
+ };
18
+ export type OrbPoint = {
19
+ x: number;
20
+ y: number;
21
+ z: number;
22
+ };
23
+ export declare const LISTENING_CYCLE_MS = 3000;
24
+ export declare const LISTENING_SPEED_RANGE: {
25
+ min: number;
26
+ max: number;
27
+ };
28
+ export declare const LISTENING_SCALE_RANGE: {
29
+ min: number;
30
+ max: number;
31
+ };
32
+ /**
33
+ * FNV-1a. A string seed has to produce the same number on every mount, or the
34
+ * orb a consumer keyed to a session id would look different on each navigation
35
+ * — which is the whole reason the prop exists.
36
+ */
37
+ export declare const hashSeed: (value: string) => number;
38
+ /**
39
+ * Turns an explicit `seed` prop into the integer `mulberry32` wants, or null
40
+ * when there is nothing explicit to resolve -- the caller then falls back to
41
+ * its own stable per-mount random value rather than treating null as zero.
42
+ *
43
+ * A numeric-looking string is parsed back into the number it names rather
44
+ * than hashed. Every custom-element attribute arrives as a string -- HTML has
45
+ * no way to write a "number" attribute -- so without this, the web
46
+ * component's `<sui-voice-orb seed="42">` would hash `"42"` while the Svelte
47
+ * `seed={42}` it is meant to be the markup spelling of used the number
48
+ * directly, and the same intended seed would paint two different orbs
49
+ * depending on which API reached it. A non-numeric string (a session id)
50
+ * still hashes, unchanged.
51
+ */
52
+ export declare const resolveExplicitSeed: (seed?: string | number) => number | null;
53
+ /** Mulberry32: small, fast, and reproducible from one integer. */
54
+ export declare const mulberry32: (initial: number) => (() => number);
55
+ /**
56
+ * Points spread evenly over a sphere.
57
+ *
58
+ * `cos(phi)` is taken uniformly in [-1, 1] rather than `phi` itself: sampling
59
+ * the angle uniformly packs points towards the poles, because the rings of
60
+ * constant `phi` near a pole have far less circumference than those at the
61
+ * equator. The visible symptom is two dense caps and a sparse middle.
62
+ */
63
+ export declare const generateOrbParticles: (count: number, radius: number, random: () => number) => readonly OrbParticle[];
64
+ /** Rotates about X then Y, which is the order the projection assumes. */
65
+ export declare const rotateOrbPoint: (point: OrbPoint, angleX: number, angleY: number) => OrbPoint;
66
+ /**
67
+ * Perspective depth for a point: 1 at the sphere's centre plane, larger as it
68
+ * comes towards the viewer. Used for both scale and opacity, so a particle
69
+ * behind the sphere reads as further away.
70
+ */
71
+ export declare const orbDepthScale: (z: number, focalLength: number) => number;
72
+ /** The 0..1 breathing envelope, one full cycle per `LISTENING_CYCLE_MS`. */
73
+ export declare const listeningWave: (elapsedMs: number) => number;
74
+ export declare const listeningSpeed: (elapsedMs: number, speedMultiplier: number) => number;
75
+ export declare const listeningScale: (elapsedMs: number) => number;
76
+ /**
77
+ * Parses the two colour shapes a browser hands back: `getComputedStyle().color`
78
+ * is always `rgb()`/`rgba()`, and a canvas `fillStyle` read back after a write
79
+ * is always `#rrggbb` (or `rgba()` when it carries alpha). Between them those
80
+ * cover every CSS colour syntax, because the canvas does the hard parsing --
81
+ * `oklch()`, `color(display-p3 ...)` and named colours all normalise through it.
82
+ *
83
+ * The library's own `hexToRgb` handles only hex, which is why this exists
84
+ * alongside it rather than replacing it.
85
+ */
86
+ export declare const parseCssColor: (value: string) => Rgb | null;
87
+ /** Scale at silence and at full level. Capped at 1 so a shout cannot clip the sphere. */
88
+ export declare const AUDIO_SCALE_RANGE: {
89
+ min: number;
90
+ max: number;
91
+ };
92
+ /** Rotation multiplier at silence and at full level, before `speedMultiplier`. */
93
+ export declare const AUDIO_SPEED_RANGE: {
94
+ min: number;
95
+ max: number;
96
+ };
97
+ /**
98
+ * How far the level must move before `onlevel` fires again.
99
+ *
100
+ * Without a threshold the callback runs every frame forever, including through
101
+ * silence, and a consumer who sets component state from it re-renders 60 times
102
+ * a second for no reason. A hundredth of the range is below anything a meter
103
+ * or a caption can show.
104
+ */
105
+ export declare const LEVEL_REPORT_EPSILON = 0.01;
106
+ /** Envelope-follower time constants. Fast onset, unhurried decay. */
107
+ export declare const AUDIO_ATTACK_MS = 60;
108
+ export declare const AUDIO_RELEASE_MS = 320;
109
+ /**
110
+ * RMS of one buffer of byte time-domain data, as 0..1.
111
+ *
112
+ * `getByteTimeDomainData` centres silence on 128, so each sample is an
113
+ * excursion from that midpoint rather than a magnitude. Treating the raw byte
114
+ * as amplitude reads silence as roughly full scale.
115
+ */
116
+ export declare const rmsFromTimeDomain: (samples: Uint8Array) => number;
117
+ /**
118
+ * One-pole envelope follower, stepped by wall-clock rather than by frame, so
119
+ * the response is the same on a 60Hz and a 120Hz display.
120
+ *
121
+ * Separate attack and release constants are the point: speech is a burst
122
+ * followed by a gap, and a symmetric filter either chatters through every
123
+ * syllable or lags the onset that makes the orb feel connected to the voice.
124
+ */
125
+ export declare const followEnvelope: (current: number, target: number, deltaMs: number, attackMs: number, releaseMs: number) => number;
126
+ export declare const audioScale: (level: number) => number;
127
+ export declare const audioSpeed: (level: number, speedMultiplier: number) => number;
128
+ /** Effective rotation speed for one frame. See the reduced-motion note above. */
129
+ export declare const resolveMotionSpeed: (speedMultiplier: number, variant: VoiceOrbVariant, listeningElapsedMs: number, level: number | null, reducedMotion: boolean) => number;
130
+ /** Effective breathing scale for one frame. Same split as `resolveMotionSpeed`. */
131
+ export declare const resolveMotionScale: (variant: VoiceOrbVariant, listeningElapsedMs: number, level: number | null, reducedMotion: boolean) => number;