@juspay/svelte-ui-components 4.32.1 → 4.33.1

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,357 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { attract, GRAVITY_DEFAULTS, type GravityPointer } from './orb/gravity';
4
+ import { paintFrame } from './orb/paint';
5
+ import { MODES } from './orb/modes';
6
+ import { prefersReducedMotion } from '../utils';
7
+ import { parseCssColor } from '../VoiceOrb/orbMath';
8
+ import type { Rgb } from '../types';
9
+ import type {
10
+ GravityOptions,
11
+ OrbState,
12
+ ThinkingOrbProperties,
13
+ ThinkingOrbSize
14
+ } from './properties';
15
+
16
+ let {
17
+ state: orbState = 'working',
18
+ size = 64,
19
+ speed = 1,
20
+ paused = false,
21
+ color,
22
+ dots = 1,
23
+ dotSize = 1,
24
+ gravity = false,
25
+ ariaLabel,
26
+ classes,
27
+ testId,
28
+ onfirstframe
29
+ }: ThinkingOrbProperties = $props();
30
+
31
+ const SIZES: readonly ThinkingOrbSize[] = [64, 32, 20];
32
+ const FALLBACK_TINT: Rgb = { r: 128, g: 128, b: 128 };
33
+ /** A fixed, representative instant a reduced-motion orb holds — any single
34
+ * frame works, since every mode is required to be non-blank at every `t`. */
35
+ const REDUCED_MOTION_INSTANT = 2.4;
36
+ /** The backing store never scales past this multiple of the CSS size, so a
37
+ * very-high-density display can't push the canvas past a sane pixel budget. */
38
+ const MAX_BACKING_SCALE = 2;
39
+
40
+ // <sui-thinking-orb> passes `state`/`size` straight from string/number
41
+ // attributes, so a value no mode or size knows about can reach here. Fall
42
+ // back rather than let a missing MODES entry leave nothing drawn.
43
+ const safeState: OrbState = $derived(Object.hasOwn(MODES, orbState) ? orbState : 'working');
44
+ const safeSize: ThinkingOrbSize = $derived(SIZES.includes(size) ? size : 64);
45
+ const density = $derived(Math.max(0.1, dots));
46
+ const dotScale = $derived(Math.max(0.1, dotSize));
47
+
48
+ /*
49
+ * Gravity is motion: under reduced motion it is switched off outright
50
+ * rather than merely un-animated, so a still frame never carries a pointer
51
+ * pull a user asked motion to not see.
52
+ */
53
+ const gravityOptions = $derived.by((): GravityOptions | null => {
54
+ if (!gravity || reducedMotion) {
55
+ return null;
56
+ }
57
+ return gravity === true ? GRAVITY_DEFAULTS : gravity;
58
+ });
59
+
60
+ let canvas: HTMLCanvasElement | null = $state(null);
61
+ /*
62
+ * `reducedMotion`/`onScreen`/`tabVisible` feed `shouldRun` below (a
63
+ * `$derived`), so they must be real `$state` for it to react when they
64
+ * change. `pointer` is a plain `let`, not `$state`: a still orb repaints
65
+ * explicitly on pointer events (see `repaintIfStill`), and a running orb's
66
+ * loop already reads the latest pointer every tick, so retriggering the
67
+ * whole effect on every pointer move would only tear down and restart the
68
+ * loop for nothing. A theme change works the same way: a still orb
69
+ * repaints explicitly (see `repaintIfColorChanged`) once its *resolved*
70
+ * colour actually moves, rather than through a theme-flavoured state write
71
+ * -- the paint never needs to know the theme itself, only the colour it
72
+ * comes out to.
73
+ */
74
+ let reducedMotion = $state(prefersReducedMotion());
75
+ let onScreen = $state(true);
76
+ let tabVisible = $state(true);
77
+ let announcedFirstFrame = false;
78
+ let resolvedColorInput = '';
79
+ let resolvedTint: Rgb = FALLBACK_TINT;
80
+ let pointer: GravityPointer = null;
81
+ let animationHandle = 0;
82
+ /** The orb's own clock: speed-scaled seconds, starting at 0 on mount and
83
+ * advancing only while the loop actually runs (see the main `$effect`). A
84
+ * still orb simply stops touching it, so it holds the exact instant it
85
+ * stopped at with no extra bookkeeping. */
86
+ let clock = 0;
87
+
88
+ const shouldRun = $derived(!paused && !reducedMotion && onScreen && tabVisible);
89
+
90
+ /** What a still orb paints: the fixed instant under reduced motion, else
91
+ * whatever `clock` last reached. */
92
+ const heldInstant = (): number => (reducedMotion ? REDUCED_MOTION_INSTANT : clock);
93
+
94
+ /*
95
+ * Assigning an unparseable colour to `fillStyle` is a spec no-op, so the
96
+ * candidate is tried against two different backgrounds and the results
97
+ * compared -- agreement means the browser accepted and normalised it (see
98
+ * VoiceOrb.svelte, which uses the same trick).
99
+ */
100
+ const normalizeColor = (context: CanvasRenderingContext2D, value: string): string | null => {
101
+ if (value.length === 0) {
102
+ return null;
103
+ }
104
+ const previous = context.fillStyle;
105
+ context.fillStyle = '#000000';
106
+ context.fillStyle = value;
107
+ const onBlack = context.fillStyle;
108
+ context.fillStyle = '#ffffff';
109
+ context.fillStyle = value;
110
+ const onWhite = context.fillStyle;
111
+ context.fillStyle = previous;
112
+ return typeof onBlack === 'string' && onBlack === onWhite ? onBlack : null;
113
+ };
114
+
115
+ const readColorInput = (
116
+ target: HTMLCanvasElement
117
+ ): { declared: string; inherited: string; key: string } => {
118
+ const styles = getComputedStyle(target);
119
+ const declared = styles.getPropertyValue('--sui-thinking-orb-color').trim();
120
+ const inherited = styles.color.trim();
121
+ return { declared, inherited, key: `${declared}|${inherited}` };
122
+ };
123
+
124
+ /** `color` prop wins outright; otherwise re-read via getComputedStyle, cached by the raw input string. */
125
+ const resolveTint = (context: CanvasRenderingContext2D): Rgb => {
126
+ if (color) {
127
+ return color;
128
+ }
129
+ if (canvas === null) {
130
+ return resolvedTint;
131
+ }
132
+ const { declared, inherited, key } = readColorInput(canvas);
133
+ if (key === resolvedColorInput) {
134
+ return resolvedTint;
135
+ }
136
+ resolvedColorInput = key;
137
+ const normalized = normalizeColor(context, declared) ?? normalizeColor(context, inherited);
138
+ resolvedTint = (normalized === null ? null : parseCssColor(normalized)) ?? FALLBACK_TINT;
139
+ return resolvedTint;
140
+ };
141
+
142
+ /** A still orb has no running loop to pick up a moved pointer on its own,
143
+ * so gravity repaints it directly; a running orb's loop already paints the
144
+ * latest pointer every tick and needs no help here. */
145
+ const repaintIfStill = (): void => {
146
+ if (shouldRun || gravityOptions === null) {
147
+ return;
148
+ }
149
+ draw(heldInstant());
150
+ };
151
+
152
+ /** A still orb repaints only when its resolved colour actually changed --
153
+ * whatever mechanism the host used to flip theme (an attribute on `<html>`
154
+ * or `<body>`, or a CSS transition landing). An explicit `color` prop
155
+ * never depends on the theme, and a running orb already re-reads the
156
+ * colour every frame via `draw`, so both bail out here. `readColorInput`'s
157
+ * cached key is what `resolveTint` itself compares against, so this check
158
+ * and the eventual repaint can never disagree about whether anything moved. */
159
+ const repaintIfColorChanged = (): void => {
160
+ if (shouldRun || color || canvas === null) {
161
+ return;
162
+ }
163
+ if (readColorInput(canvas).key !== resolvedColorInput) {
164
+ draw(heldInstant());
165
+ }
166
+ };
167
+
168
+ const trackPointer = (event: PointerEvent): void => {
169
+ if (canvas === null || !gravity) {
170
+ return;
171
+ }
172
+ const rect = canvas.getBoundingClientRect();
173
+ pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
174
+ repaintIfStill();
175
+ };
176
+
177
+ const releasePointer = (): void => {
178
+ pointer = null;
179
+ repaintIfStill();
180
+ };
181
+
182
+ /** SSR renders with no `window`; the backing store is left at 1x until the
183
+ * component actually mounts in a browser and can ask it for a real ratio. */
184
+ const resolveBackingScale = (): number => {
185
+ if (typeof window === 'undefined') {
186
+ return 1;
187
+ }
188
+ return Math.min(MAX_BACKING_SCALE, window.devicePixelRatio ?? 1);
189
+ };
190
+
191
+ /** Paints one instant. `instant` is already speed-scaled seconds -- either
192
+ * the orb's own running `clock` or the fixed reduced-motion instant. Safe
193
+ * to call whether or not the loop is running. */
194
+ const draw = (instant: number): void => {
195
+ if (canvas === null) {
196
+ return;
197
+ }
198
+ const context = canvas.getContext('2d');
199
+ if (context === null) {
200
+ return;
201
+ }
202
+
203
+ const dpr = resolveBackingScale();
204
+ const backingPx = Math.round(safeSize * dpr);
205
+ if (canvas.width !== backingPx || canvas.height !== backingPx) {
206
+ canvas.width = backingPx;
207
+ canvas.height = backingPx;
208
+ }
209
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
210
+
211
+ const frame = MODES[safeState](instant, { size: safeSize, density, dotScale });
212
+ const gOpts = gravityOptions;
213
+ const painted = gOpts === null ? frame : attract(frame, pointer, gOpts);
214
+ paintFrame(context, painted, { tint: resolveTint(context) });
215
+
216
+ if (!announcedFirstFrame) {
217
+ announcedFirstFrame = true;
218
+ onfirstframe?.();
219
+ }
220
+ };
221
+
222
+ /*
223
+ * Re-runs whenever `shouldRun` or anything the leading `draw` reads changes
224
+ * (`safeState`, `safeSize`, `density`, `dotScale`, `gravityOptions`,
225
+ * `color`, `reducedMotion`, ...) -- Svelte tracks every reactive read inside
226
+ * this body, including transitively through that synchronous `draw` call,
227
+ * so a prop change tears down the previous rAF (the cleanup below) and
228
+ * repaints from scratch with no dependency list to maintain by hand.
229
+ *
230
+ * `clock` itself is never read synchronously here, only inside the `loop`
231
+ * closure below, which runs later from `requestAnimationFrame` -- so
232
+ * advancing it never retriggers this effect. That's what lets `speed`
233
+ * change the clock's rate starting next tick with no jump and no loop
234
+ * restart: `loop` reads the live `speed` prop itself, every tick.
235
+ */
236
+ // eslint-disable-next-line no-restricted-syntax
237
+ $effect(() => {
238
+ // A still orb (paused, reduced motion or off screen) never touches
239
+ // `clock`, so it simply holds whatever instant it last reached -- no
240
+ // separate "was it frozen" bookkeeping needed. At least one frame is
241
+ // always painted, so the canvas is never blank.
242
+ draw(heldInstant());
243
+ if (!shouldRun) {
244
+ return;
245
+ }
246
+ // Reset on every (re)start of the loop, including a resume from
247
+ // stillness: the first tick after a (re)start must not count the time
248
+ // that passed before it, so it advances `clock` by zero and every tick
249
+ // after that measures real elapsed time again.
250
+ let previousFrameTime: number | null = null;
251
+ const loop = (frameTime: number): void => {
252
+ const deltaSeconds = previousFrameTime === null ? 0 : (frameTime - previousFrameTime) / 1000;
253
+ previousFrameTime = frameTime;
254
+ clock += deltaSeconds * speed;
255
+ draw(clock);
256
+ animationHandle = requestAnimationFrame(loop);
257
+ };
258
+ animationHandle = requestAnimationFrame(loop);
259
+ return () => cancelAnimationFrame(animationHandle);
260
+ });
261
+
262
+ onMount(() => {
263
+ if (canvas === null) {
264
+ return;
265
+ }
266
+
267
+ // Every optional platform API below follows the same shape: build it
268
+ // when the browser exposes the constructor/method it needs, otherwise
269
+ // leave the binding `null` so the optional-chaining calls below no-op.
270
+ let intersectionObserver: IntersectionObserver | null = null;
271
+ if (typeof IntersectionObserver !== 'undefined') {
272
+ intersectionObserver = new IntersectionObserver(([entry]) => {
273
+ onScreen = entry.isIntersecting;
274
+ });
275
+ intersectionObserver.observe(canvas);
276
+ }
277
+
278
+ const onVisibilityChange = (): void => {
279
+ tabVisible = document.visibilityState !== 'hidden';
280
+ };
281
+ document.addEventListener('visibilitychange', onVisibilityChange);
282
+
283
+ /*
284
+ * The orb never asks how a host marks its theme -- `theme`, `data-theme`,
285
+ * a class, an inline `style`, `color-scheme`, on `<html>` or `<body>`, or
286
+ * anything else -- it only cares whether the colour it actually resolves
287
+ * to (see `resolveTint`) moved. So this observes every attribute change
288
+ * on both elements, unfiltered, and lets `repaintIfColorChanged` decide
289
+ * whether anything worth a repaint happened.
290
+ */
291
+ let themeObserver: MutationObserver | null = null;
292
+ if (typeof MutationObserver !== 'undefined') {
293
+ themeObserver = new MutationObserver(repaintIfColorChanged);
294
+ themeObserver.observe(document.documentElement, { attributes: true });
295
+ themeObserver.observe(document.body, { attributes: true });
296
+ }
297
+
298
+ const onMotionChange = (event: MediaQueryListEvent): void => {
299
+ reducedMotion = event.matches;
300
+ };
301
+ let motionQuery: MediaQueryList | null = null;
302
+ if (typeof window.matchMedia === 'function') {
303
+ motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
304
+ motionQuery.addEventListener('change', onMotionChange);
305
+ }
306
+
307
+ // A host that themes only through `@media (prefers-color-scheme)` changes
308
+ // the inherited colour when the OS flips theme, with no attribute change
309
+ // and no transition for the checks around it to catch.
310
+ let colorSchemeQuery: MediaQueryList | null = null;
311
+ if (typeof window.matchMedia === 'function') {
312
+ colorSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
313
+ colorSchemeQuery.addEventListener('change', repaintIfColorChanged);
314
+ }
315
+
316
+ /*
317
+ * A host that transitions its `color` (this repo's own layout fades
318
+ * `body` across a theme switch) leaves a just-frozen frame at the
319
+ * outgoing colour, with nothing else to redraw it: no attribute changed,
320
+ * so the observer above never fires. Re-check once the transition lands.
321
+ */
322
+ const onColorTransitionEnd = (event: TransitionEvent): void => {
323
+ if (event.propertyName !== 'color' && event.propertyName !== '--sui-thinking-orb-color') {
324
+ return;
325
+ }
326
+ repaintIfColorChanged();
327
+ };
328
+ document.addEventListener('transitionend', onColorTransitionEnd);
329
+
330
+ return () => {
331
+ intersectionObserver?.disconnect();
332
+ document.removeEventListener('visibilitychange', onVisibilityChange);
333
+ document.removeEventListener('transitionend', onColorTransitionEnd);
334
+ themeObserver?.disconnect();
335
+ motionQuery?.removeEventListener('change', onMotionChange);
336
+ colorSchemeQuery?.removeEventListener('change', repaintIfColorChanged);
337
+ };
338
+ });
339
+ </script>
340
+
341
+ <!-- The canvas is a picture with a label, not a control: the pointer handlers
342
+ only feed the optional gravity effect and never act, so role="img" is
343
+ accurate. -->
344
+ <!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
345
+ <canvas
346
+ bind:this={canvas}
347
+ role="img"
348
+ aria-label={ariaLabel ?? `${safeState.charAt(0).toUpperCase()}${safeState.slice(1)}…`}
349
+ class={classes}
350
+ data-pw={typeof testId === 'string' ? testId : null}
351
+ style:width={`${safeSize}px`}
352
+ style:height={`${safeSize}px`}
353
+ style:display="block"
354
+ onpointermove={trackPointer}
355
+ onpointerleave={releasePointer}
356
+ onpointercancel={releasePointer}
357
+ ></canvas>
@@ -0,0 +1,4 @@
1
+ import type { ThinkingOrbProperties } from './properties';
2
+ declare const ThinkingOrb: import("svelte").Component<ThinkingOrbProperties, {}, "">;
3
+ type ThinkingOrb = ReturnType<typeof ThinkingOrb>;
4
+ export default ThinkingOrb;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pulls a frame's dots toward a pointer position, so an orb can feel alive
3
+ * under the cursor without any mode ever knowing pointers exist -- the same
4
+ * separation `paintFrame` keeps between a mode and the canvas.
5
+ */
6
+ import type { Frame } from './types';
7
+ /**
8
+ * Where gravity pulls toward, in the same coordinate space as a `Dot`.
9
+ * `null` means nothing is hovering, and gravity does nothing.
10
+ */
11
+ export type GravityPointer = {
12
+ x: number;
13
+ y: number;
14
+ } | null;
15
+ /** How far gravity reaches (`radius`, px) and how hard it pulls at its centre (`strength`, px). */
16
+ export type GravityOptions = {
17
+ radius: number;
18
+ strength: number;
19
+ };
20
+ /** A reach and pull tuned to read as a subtle few px of drift, never a snap to the cursor. */
21
+ export declare const GRAVITY_DEFAULTS: GravityOptions;
22
+ /**
23
+ * Returns a NEW `Frame` with every dot and stroke endpoint inside
24
+ * `options.radius` of `pointer` pulled toward it, the pull smoothly fading
25
+ * to nothing at the radius edge; marks outside are returned untouched. Each
26
+ * stroke endpoint moves by the same amount a dot at that point would, so a
27
+ * line stays attached to the nodes it joins. A `null` pointer is a no-op:
28
+ * `frame` is returned as-is. Never mutates `frame` or anything inside it.
29
+ */
30
+ export declare const attract: (frame: Frame, pointer: GravityPointer, options: GravityOptions) => Frame;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pulls a frame's dots toward a pointer position, so an orb can feel alive
3
+ * under the cursor without any mode ever knowing pointers exist -- the same
4
+ * separation `paintFrame` keeps between a mode and the canvas.
5
+ */
6
+ import { easeOutCubic } from './space';
7
+ /** A reach and pull tuned to read as a subtle few px of drift, never a snap to the cursor. */
8
+ export const GRAVITY_DEFAULTS = {
9
+ radius: 32,
10
+ strength: 6
11
+ };
12
+ const clamp01 = (value) => Math.max(0, Math.min(1, value));
13
+ /**
14
+ * Where one point ends up after `pointer` pulls on it. The pull is capped at
15
+ * the point's own distance from `pointer` -- without that cap, a point
16
+ * already close to the pointer (distance smaller than `strength`) would
17
+ * overshoot past it. Shared by dots and stroke endpoints so a line stays
18
+ * attached to the nodes it joins: each endpoint moves exactly as far as a
19
+ * dot sitting at that same spot would.
20
+ */
21
+ const attractPoint = (point, pointer, options) => {
22
+ const dx = pointer.x - point.x;
23
+ const dy = pointer.y - point.y;
24
+ const distance = Math.hypot(dx, dy);
25
+ if (distance === 0 || distance >= options.radius) {
26
+ return point;
27
+ }
28
+ const proximity = clamp01(1 - distance / options.radius);
29
+ const pull = Math.min(easeOutCubic(proximity) * options.strength, distance);
30
+ const scale = pull / distance;
31
+ return { x: point.x + dx * scale, y: point.y + dy * scale };
32
+ };
33
+ const attractDot = (dot, pointer, options) => {
34
+ const moved = attractPoint(dot, pointer, options);
35
+ return moved.x === dot.x && moved.y === dot.y ? dot : { ...dot, x: moved.x, y: moved.y };
36
+ };
37
+ const attractStroke = (stroke, pointer, options) => {
38
+ const start = attractPoint({ x: stroke.x1, y: stroke.y1 }, pointer, options);
39
+ const end = attractPoint({ x: stroke.x2, y: stroke.y2 }, pointer, options);
40
+ if (start.x === stroke.x1 &&
41
+ start.y === stroke.y1 &&
42
+ end.x === stroke.x2 &&
43
+ end.y === stroke.y2) {
44
+ return stroke;
45
+ }
46
+ return { ...stroke, x1: start.x, y1: start.y, x2: end.x, y2: end.y };
47
+ };
48
+ /**
49
+ * Returns a NEW `Frame` with every dot and stroke endpoint inside
50
+ * `options.radius` of `pointer` pulled toward it, the pull smoothly fading
51
+ * to nothing at the radius edge; marks outside are returned untouched. Each
52
+ * stroke endpoint moves by the same amount a dot at that point would, so a
53
+ * line stays attached to the nodes it joins. A `null` pointer is a no-op:
54
+ * `frame` is returned as-is. Never mutates `frame` or anything inside it.
55
+ */
56
+ export const attract = (frame, pointer, options) => {
57
+ if (pointer === null) {
58
+ return frame;
59
+ }
60
+ return {
61
+ dots: frame.dots.map((dot) => attractDot(dot, pointer, options)),
62
+ strokes: frame.strokes.map((stroke) => attractStroke(stroke, pointer, options))
63
+ };
64
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The "form" modes: composing, breathing and shaping. Each one reads as a
3
+ * single evolving surface or ring rather than a swarm of independent points,
4
+ * so the motion stays legible down at 20px instead of dissolving into noise
5
+ * the way a large loose point cloud would.
6
+ *
7
+ * All three share one placement idiom: build each dot in its own object
8
+ * space (a band or ring parametrised by its index, deformed however that
9
+ * mode's intent calls for), spin the whole thing with `rotateY` for a sense
10
+ * of turning, tilt it with `rotateX` so it reads in 3D, then `project`. That
11
+ * split keeps "the shape's own motion" and "the camera's fixed angle on it"
12
+ * from tangling together in the per-dot math.
13
+ */
14
+ import type { ModeFn } from '../types';
15
+ /**
16
+ * A ribbon of dots looped once around the sphere on its own tilted great
17
+ * circle -- distinct from the spin axis -- rippling along its length like a
18
+ * travelling wave through cloth while the whole ribbon turns.
19
+ */
20
+ export declare const composing: ModeFn;
21
+ /**
22
+ * A single ring that swells and relaxes on a slow sine, with a faint,
23
+ * higher-harmonic wobble layered on top so its outline never reads as a
24
+ * perfectly static circle even at rest.
25
+ */
26
+ export declare const breathing: ModeFn;
27
+ /**
28
+ * A ring that morphs circle to triangle to square and back over a fixed
29
+ * cycle, each leg eased so the shape comes to rest at every corner instead
30
+ * of snapping into it -- which also makes the wrap from the last leg back to
31
+ * the first exactly continuous.
32
+ */
33
+ export declare const shaping: ModeFn;