@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,267 @@
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
+ const SIZE_RANDOMNESS = 0.95;
11
+ const BASE_PARTICLE_SIZE = 1.6;
12
+ export const LISTENING_CYCLE_MS = 3000;
13
+ export const LISTENING_SPEED_RANGE = { min: 0.8, max: 1.2 };
14
+ export const LISTENING_SCALE_RANGE = { min: 0.9, max: 1 };
15
+ /**
16
+ * FNV-1a. A string seed has to produce the same number on every mount, or the
17
+ * orb a consumer keyed to a session id would look different on each navigation
18
+ * — which is the whole reason the prop exists.
19
+ */
20
+ export const hashSeed = (value) => {
21
+ let hash = 2166136261 >>> 0;
22
+ for (let index = 0; index < value.length; index += 1) {
23
+ hash ^= value.charCodeAt(index);
24
+ hash = Math.imul(hash, 16777619);
25
+ }
26
+ return hash >>> 0;
27
+ };
28
+ /**
29
+ * Turns an explicit `seed` prop into the integer `mulberry32` wants, or null
30
+ * when there is nothing explicit to resolve -- the caller then falls back to
31
+ * its own stable per-mount random value rather than treating null as zero.
32
+ *
33
+ * A numeric-looking string is parsed back into the number it names rather
34
+ * than hashed. Every custom-element attribute arrives as a string -- HTML has
35
+ * no way to write a "number" attribute -- so without this, the web
36
+ * component's `<sui-voice-orb seed="42">` would hash `"42"` while the Svelte
37
+ * `seed={42}` it is meant to be the markup spelling of used the number
38
+ * directly, and the same intended seed would paint two different orbs
39
+ * depending on which API reached it. A non-numeric string (a session id)
40
+ * still hashes, unchanged.
41
+ */
42
+ export const resolveExplicitSeed = (seed) => {
43
+ if (typeof seed === 'number' && Number.isFinite(seed)) {
44
+ return seed >>> 0;
45
+ }
46
+ if (typeof seed !== 'string' || seed.trim().length === 0) {
47
+ return null;
48
+ }
49
+ const numeric = Number(seed);
50
+ return Number.isFinite(numeric) ? numeric >>> 0 : hashSeed(seed);
51
+ };
52
+ /** Mulberry32: small, fast, and reproducible from one integer. */
53
+ export const mulberry32 = (initial) => {
54
+ let state = initial | 0;
55
+ return () => {
56
+ state = (state + 0x6d2b79f5) | 0;
57
+ let t = state;
58
+ t = Math.imul(t ^ (t >>> 15), t | 1);
59
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
60
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
61
+ };
62
+ };
63
+ /**
64
+ * Points spread evenly over a sphere.
65
+ *
66
+ * `cos(phi)` is taken uniformly in [-1, 1] rather than `phi` itself: sampling
67
+ * the angle uniformly packs points towards the poles, because the rings of
68
+ * constant `phi` near a pole have far less circumference than those at the
69
+ * equator. The visible symptom is two dense caps and a sparse middle.
70
+ */
71
+ export const generateOrbParticles = (count, radius, random) => {
72
+ const safeCount = Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
73
+ const safeRadius = Number.isFinite(radius) && radius > 0 ? radius : 140;
74
+ const minMultiplier = 1 - SIZE_RANDOMNESS * 0.7;
75
+ const maxMultiplier = 1 + SIZE_RANDOMNESS * 0.2;
76
+ const particles = [];
77
+ for (let index = 0; index < safeCount; index += 1) {
78
+ const theta = random() * Math.PI * 2;
79
+ const phi = Math.acos(Math.max(-1, Math.min(1, 2 * random() - 1)));
80
+ const sinPhi = Math.sin(phi);
81
+ particles.push({
82
+ baseX: safeRadius * sinPhi * Math.cos(theta),
83
+ baseY: safeRadius * sinPhi * Math.sin(theta),
84
+ baseZ: safeRadius * Math.cos(phi),
85
+ size: BASE_PARTICLE_SIZE * (minMultiplier + random() * (maxMultiplier - minMultiplier))
86
+ });
87
+ }
88
+ return particles;
89
+ };
90
+ /** Rotates about X then Y, which is the order the projection assumes. */
91
+ export const rotateOrbPoint = (point, angleX, angleY) => {
92
+ const cosX = Math.cos(angleX);
93
+ const sinX = Math.sin(angleX);
94
+ const y = point.y * cosX - point.z * sinX;
95
+ const z = point.y * sinX + point.z * cosX;
96
+ const cosY = Math.cos(angleY);
97
+ const sinY = Math.sin(angleY);
98
+ return {
99
+ x: z * sinY + point.x * cosY,
100
+ y,
101
+ z: z * cosY - point.x * sinY
102
+ };
103
+ };
104
+ /**
105
+ * Perspective depth for a point: 1 at the sphere's centre plane, larger as it
106
+ * comes towards the viewer. Used for both scale and opacity, so a particle
107
+ * behind the sphere reads as further away.
108
+ */
109
+ export const orbDepthScale = (z, focalLength) => focalLength / (focalLength + z);
110
+ /** The 0..1 breathing envelope, one full cycle per `LISTENING_CYCLE_MS`. */
111
+ export const listeningWave = (elapsedMs) => {
112
+ const normalized = (elapsedMs % LISTENING_CYCLE_MS) / LISTENING_CYCLE_MS;
113
+ return (Math.sin(normalized * Math.PI * 2) + 1) / 2;
114
+ };
115
+ export const listeningSpeed = (elapsedMs, speedMultiplier) => {
116
+ const base = Number.isFinite(speedMultiplier) ? speedMultiplier : 1;
117
+ const wave = listeningWave(elapsedMs);
118
+ return (base *
119
+ (LISTENING_SPEED_RANGE.min + wave * (LISTENING_SPEED_RANGE.max - LISTENING_SPEED_RANGE.min)));
120
+ };
121
+ export const listeningScale = (elapsedMs) => {
122
+ const wave = listeningWave(elapsedMs);
123
+ return LISTENING_SCALE_RANGE.min + wave * (LISTENING_SCALE_RANGE.max - LISTENING_SCALE_RANGE.min);
124
+ };
125
+ /**
126
+ * Parses the two colour shapes a browser hands back: `getComputedStyle().color`
127
+ * is always `rgb()`/`rgba()`, and a canvas `fillStyle` read back after a write
128
+ * is always `#rrggbb` (or `rgba()` when it carries alpha). Between them those
129
+ * cover every CSS colour syntax, because the canvas does the hard parsing --
130
+ * `oklch()`, `color(display-p3 ...)` and named colours all normalise through it.
131
+ *
132
+ * The library's own `hexToRgb` handles only hex, which is why this exists
133
+ * alongside it rather than replacing it.
134
+ */
135
+ export const parseCssColor = (value) => {
136
+ const trimmed = value.trim();
137
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(trimmed);
138
+ if (hex !== null) {
139
+ const digits = hex[1];
140
+ const expanded = digits.length === 3
141
+ ? `${digits[0]}${digits[0]}${digits[1]}${digits[1]}${digits[2]}${digits[2]}`
142
+ : digits;
143
+ return {
144
+ r: Number.parseInt(expanded.slice(0, 2), 16),
145
+ g: Number.parseInt(expanded.slice(2, 4), 16),
146
+ b: Number.parseInt(expanded.slice(4, 6), 16)
147
+ };
148
+ }
149
+ const functional = /^rgba?\(([^)]*)\)$/i.exec(trimmed);
150
+ if (functional === null) {
151
+ return null;
152
+ }
153
+ const channels = functional[1]
154
+ .split(/[,\s/]+/)
155
+ .filter((part) => part.length > 0)
156
+ .map(Number);
157
+ if (channels.length < 3 || channels.slice(0, 3).some((channel) => !Number.isFinite(channel))) {
158
+ return null;
159
+ }
160
+ const clamp = (channel) => Math.max(0, Math.min(255, Math.round(channel)));
161
+ return { r: clamp(channels[0]), g: clamp(channels[1]), b: clamp(channels[2]) };
162
+ };
163
+ /*
164
+ * Audio-reactive envelope.
165
+ *
166
+ * The component never opens a microphone. It reads an `AnalyserNode` the
167
+ * consumer already owns, which keeps permissions, the `AudioContext` lifecycle
168
+ * and the choice of source (microphone, an element, a synthesised tone) on the
169
+ * side of the application that has the context to decide them.
170
+ */
171
+ /** Scale at silence and at full level. Capped at 1 so a shout cannot clip the sphere. */
172
+ export const AUDIO_SCALE_RANGE = { min: 0.82, max: 1 };
173
+ /** Rotation multiplier at silence and at full level, before `speedMultiplier`. */
174
+ export const AUDIO_SPEED_RANGE = { min: 0.5, max: 2.4 };
175
+ /**
176
+ * How far the level must move before `onlevel` fires again.
177
+ *
178
+ * Without a threshold the callback runs every frame forever, including through
179
+ * silence, and a consumer who sets component state from it re-renders 60 times
180
+ * a second for no reason. A hundredth of the range is below anything a meter
181
+ * or a caption can show.
182
+ */
183
+ export const LEVEL_REPORT_EPSILON = 0.01;
184
+ /** Envelope-follower time constants. Fast onset, unhurried decay. */
185
+ export const AUDIO_ATTACK_MS = 60;
186
+ export const AUDIO_RELEASE_MS = 320;
187
+ /**
188
+ * RMS of one buffer of byte time-domain data, as 0..1.
189
+ *
190
+ * `getByteTimeDomainData` centres silence on 128, so each sample is an
191
+ * excursion from that midpoint rather than a magnitude. Treating the raw byte
192
+ * as amplitude reads silence as roughly full scale.
193
+ */
194
+ export const rmsFromTimeDomain = (samples) => {
195
+ if (samples.length === 0) {
196
+ return 0;
197
+ }
198
+ let total = 0;
199
+ for (const sample of samples) {
200
+ const excursion = (sample - 128) / 128;
201
+ total += excursion * excursion;
202
+ }
203
+ return Math.sqrt(total / samples.length);
204
+ };
205
+ /**
206
+ * One-pole envelope follower, stepped by wall-clock rather than by frame, so
207
+ * the response is the same on a 60Hz and a 120Hz display.
208
+ *
209
+ * Separate attack and release constants are the point: speech is a burst
210
+ * followed by a gap, and a symmetric filter either chatters through every
211
+ * syllable or lags the onset that makes the orb feel connected to the voice.
212
+ */
213
+ export const followEnvelope = (current, target, deltaMs, attackMs, releaseMs) => {
214
+ if (!Number.isFinite(current)) {
215
+ return 0;
216
+ }
217
+ if (!Number.isFinite(target) || !Number.isFinite(deltaMs) || deltaMs <= 0) {
218
+ return current;
219
+ }
220
+ const timeConstant = target > current ? attackMs : releaseMs;
221
+ if (!Number.isFinite(timeConstant) || timeConstant <= 0) {
222
+ return target;
223
+ }
224
+ const coefficient = 1 - Math.exp(-deltaMs / timeConstant);
225
+ return current + (target - current) * coefficient;
226
+ };
227
+ const clampLevel = (level) => Number.isFinite(level) ? Math.max(0, Math.min(1, level)) : 0;
228
+ export const audioScale = (level) => AUDIO_SCALE_RANGE.min + clampLevel(level) * (AUDIO_SCALE_RANGE.max - AUDIO_SCALE_RANGE.min);
229
+ export const audioSpeed = (level, speedMultiplier) => {
230
+ const base = Number.isFinite(speedMultiplier) ? speedMultiplier : 1;
231
+ return (base *
232
+ (AUDIO_SPEED_RANGE.min + clampLevel(level) * (AUDIO_SPEED_RANGE.max - AUDIO_SPEED_RANGE.min)));
233
+ };
234
+ /*
235
+ * `prefers-reduced-motion` support.
236
+ *
237
+ * The two decorative sources -- idle rotation and the sine-wave breathing
238
+ * `listening` rides without an `analyser` -- are what the OS setting exists to
239
+ * suppress. `level` (non-null only while a real `AnalyserNode` is actually
240
+ * driving the orb) is left alone in both functions below: it reports what the
241
+ * microphone is doing right now, which is state, not decoration, and freezing
242
+ * it would take away the only feedback a caller gave the user that the app is
243
+ * listening. Documented in docs/VoiceOrb.md's Accessibility section.
244
+ */
245
+ /** Effective rotation speed for one frame. See the reduced-motion note above. */
246
+ export const resolveMotionSpeed = (speedMultiplier, variant, listeningElapsedMs, level, reducedMotion) => {
247
+ if (level !== null) {
248
+ return audioSpeed(level, speedMultiplier);
249
+ }
250
+ if (reducedMotion) {
251
+ return 0;
252
+ }
253
+ if (variant === 'listening') {
254
+ return listeningSpeed(listeningElapsedMs, speedMultiplier);
255
+ }
256
+ return Number.isFinite(speedMultiplier) ? speedMultiplier : 1;
257
+ };
258
+ /** Effective breathing scale for one frame. Same split as `resolveMotionSpeed`. */
259
+ export const resolveMotionScale = (variant, listeningElapsedMs, level, reducedMotion) => {
260
+ if (level !== null) {
261
+ return audioScale(level);
262
+ }
263
+ if (reducedMotion || variant !== 'listening') {
264
+ return 1;
265
+ }
266
+ return listeningScale(listeningElapsedMs);
267
+ };
@@ -0,0 +1,105 @@
1
+ export type VoiceOrbVariant = 'idle' | 'listening';
2
+ export type VoiceOrbProperties = OptionalVoiceOrbProperties & VoiceOrbEventProperties;
3
+ export type MandatoryVoiceOrbProperties = Record<string, never>;
4
+ export type OptionalVoiceOrbProperties = {
5
+ /**
6
+ * Animation state. `'idle'` rotates at a steady rate; `'listening'` breathes,
7
+ * to show an assistant is taking input.
8
+ *
9
+ * What drives the breathing depends on whether you pass `analyser`. With one,
10
+ * `'listening'` is driven by the sound itself. Without one, it rides a sine
11
+ * wave and is decorative — it says "the microphone is open", never "this is
12
+ * what it hears".
13
+ */
14
+ variant?: VoiceOrbVariant;
15
+ /**
16
+ * An `AnalyserNode` to drive the orb from, making `variant='listening'` react
17
+ * to the sound instead of breathing on a timer. Ignored while `'idle'`.
18
+ *
19
+ * The node stays yours. This component never calls `getUserMedia`, never
20
+ * creates an `AudioContext` and never closes one: microphone permission, the
21
+ * context lifecycle and the choice of source are decisions that need
22
+ * application context, and a component that grabbed a microphone because it
23
+ * was mounted would be a worse component.
24
+ *
25
+ * Any source works — a live microphone, an `<audio>` element, a synthesised
26
+ * tone. Closing the context under a mounted orb is safe: the read is guarded
27
+ * and the orb falls back to the sine wave rather than freezing.
28
+ */
29
+ analyser?: AnalyserNode | null;
30
+ /**
31
+ * Multiplies the measured level before it drives the orb. Above `1` makes a
32
+ * quiet speaker fill the sphere; below `1` tames a hot input. Defaults to `1`.
33
+ *
34
+ * Only meaningful alongside `analyser`.
35
+ */
36
+ sensitivity?: number;
37
+ /**
38
+ * Multiplies the rotation rate. `1` is the designed speed; `0` freezes the
39
+ * orb mid-rotation without unmounting it.
40
+ */
41
+ speedMultiplier?: number;
42
+ /**
43
+ * Seeds particle placement, so the same value always produces the same orb.
44
+ * A number and the numeric string that names it resolve to the same seed
45
+ * -- `seed={42}` and `seed="42"` paint the same orb, which is what lets a
46
+ * custom-element attribute (always a string) agree with the Svelte prop it
47
+ * is the markup spelling of. Any other string is hashed as text. Omitted,
48
+ * every mount looks different.
49
+ *
50
+ * Pass something stable per user or per session if you want the orb to look
51
+ * like the same object across navigations.
52
+ */
53
+ seed?: string | number;
54
+ /** Rendered height in pixels. Width always fills the container. Defaults to `400`. */
55
+ height?: number;
56
+ /** Particles on the sphere. Defaults to `1000`. */
57
+ particleCount?: number;
58
+ /** Sphere radius in pixels before projection. Defaults to `140`. */
59
+ radius?: number;
60
+ /**
61
+ * Whether pointer movement pushes particles aside. Defaults to `true`.
62
+ * Set `false` for a purely ambient orb that ignores the pointer.
63
+ */
64
+ interactive?: boolean;
65
+ /** How far from the pointer the repulsion reaches, in pixels. Defaults to `200`. */
66
+ repelRadius?: number;
67
+ /**
68
+ * Persists rotation across mounts under this `localStorage` key prefix, so
69
+ * the orb resumes where it stopped rather than snapping back.
70
+ *
71
+ * Opt-in, and namespaced by the caller, deliberately. Writing to a
72
+ * consumer's storage uninvited is not a component's decision to make, and a
73
+ * fixed key would make two orbs on one page overwrite each other.
74
+ */
75
+ persistKey?: string;
76
+ /** CSS class string applied to the orb's root element. */
77
+ classes?: string;
78
+ /** Value for the `data-pw` attribute on the root, for test selectors. */
79
+ testId?: string;
80
+ };
81
+ export type VoiceOrbEventProperties = {
82
+ /**
83
+ * Fires once, on the first frame actually painted — not on mount.
84
+ *
85
+ * Named `onfirstframe` rather than `onanimationstart` because the latter is a
86
+ * real `HTMLElement` accessor for the CSS animationstart event: a web-component
87
+ * prop of that name would shadow the host's own, which `prop-parity` rejects.
88
+ *
89
+ * The gap matters: particle generation and the first paint happen after
90
+ * mount, so a loading overlay removed on mount reveals an empty canvas. This
91
+ * fires when there is something to see.
92
+ */
93
+ onfirstframe?: () => void;
94
+ /**
95
+ * The smoothed audio level, 0..1, as it changes. Only fires while `analyser`
96
+ * is actually driving the orb, plus once on the way back to 0 when it stops,
97
+ * so a meter does not stick at the last value it saw.
98
+ *
99
+ * Rate-limited by change, not by time: it fires when the level moves by at
100
+ * least 0.01, which means silence is silent rather than 60 calls a second.
101
+ * It is still a per-frame callback in the loud case — drive a DOM write or a
102
+ * cheap store from it, not an expensive re-render.
103
+ */
104
+ onlevel?: (level: number) => void;
105
+ };
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.d.ts CHANGED
@@ -37,6 +37,7 @@ export { default as Tooltip } from './Tooltip/Tooltip.svelte';
37
37
  export { default as TypewriterText } from './TypewriterText/TypewriterText.svelte';
38
38
  export { tooltip } from './Tooltip/tooltip-action';
39
39
  export { default as Shimmer } from './Shimmer/Shimmer.svelte';
40
+ export { default as VoiceOrb } from './VoiceOrb/VoiceOrb.svelte';
40
41
  export { default as Progress } from './Progress/Progress.svelte';
41
42
  export { default as Pill } from './Pill/Pill.svelte';
42
43
  export { default as Pagination } from './Pagination/Pagination.svelte';
@@ -187,6 +188,7 @@ export type * from './ChipInput/properties';
187
188
  export type * from './FileDropzoneTrigger/properties';
188
189
  export type * from './_chart/highlight';
189
190
  export type * from './_chart/narrationHighlighter';
191
+ export type * from './VoiceOrb/properties';
190
192
  export type * from './Chat/properties';
191
193
  export type * from './Chat/types';
192
194
  export type * from './SpeechToText/types';
package/dist/index.js CHANGED
@@ -37,6 +37,7 @@ export { default as Tooltip } from './Tooltip/Tooltip.svelte';
37
37
  export { default as TypewriterText } from './TypewriterText/TypewriterText.svelte';
38
38
  export { tooltip } from './Tooltip/tooltip-action';
39
39
  export { default as Shimmer } from './Shimmer/Shimmer.svelte';
40
+ export { default as VoiceOrb } from './VoiceOrb/VoiceOrb.svelte';
40
41
  export { default as Progress } from './Progress/Progress.svelte';
41
42
  export { default as Pill } from './Pill/Pill.svelte';
42
43
  export { default as Pagination } from './Pagination/Pagination.svelte';