@juspay/svelte-ui-components 4.32.0 → 4.33.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.
- package/dist/ChatComposer/ChatComposer.svelte +4 -0
- package/dist/Snippet/Snippet.svelte +7 -1
- package/dist/Table/Table.svelte +1 -0
- package/dist/ThinkingOrb/ThinkingOrb.svelte +344 -0
- package/dist/ThinkingOrb/ThinkingOrb.svelte.d.ts +4 -0
- package/dist/ThinkingOrb/orb/gravity.d.ts +30 -0
- package/dist/ThinkingOrb/orb/gravity.js +64 -0
- package/dist/ThinkingOrb/orb/modes/form.d.ts +33 -0
- package/dist/ThinkingOrb/orb/modes/form.js +201 -0
- package/dist/ThinkingOrb/orb/modes/index.d.ts +9 -0
- package/dist/ThinkingOrb/orb/modes/index.js +20 -0
- package/dist/ThinkingOrb/orb/modes/network.d.ts +12 -0
- package/dist/ThinkingOrb/orb/modes/network.js +181 -0
- package/dist/ThinkingOrb/orb/modes/sphere.d.ts +11 -0
- package/dist/ThinkingOrb/orb/modes/sphere.js +364 -0
- package/dist/ThinkingOrb/orb/paint.d.ts +25 -0
- package/dist/ThinkingOrb/orb/paint.js +47 -0
- package/dist/ThinkingOrb/orb/space.d.ts +63 -0
- package/dist/ThinkingOrb/orb/space.js +115 -0
- package/dist/ThinkingOrb/orb/types.d.ts +52 -0
- package/dist/ThinkingOrb/orb/types.js +7 -0
- package/dist/ThinkingOrb/properties.d.ts +68 -0
- package/dist/ThinkingOrb/properties.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist-codemod/codemod/migration-assets/legacy-display.css +2 -1
- package/dist-codemod/codemod/migration-assets/wc-display.json +5 -0
- package/dist-wc/index.js +1053 -436
- package/package.json +1 -1
|
@@ -225,6 +225,7 @@
|
|
|
225
225
|
{#if showAttach}
|
|
226
226
|
<div class="control attach">
|
|
227
227
|
<Button
|
|
228
|
+
variant="ghost"
|
|
228
229
|
onclick={() => {
|
|
229
230
|
if (typeof onattachclick === 'function') {
|
|
230
231
|
onattachclick();
|
|
@@ -281,6 +282,7 @@
|
|
|
281
282
|
class:busy={dictationState === 'busy'}
|
|
282
283
|
>
|
|
283
284
|
<Button
|
|
285
|
+
variant="ghost"
|
|
284
286
|
onclick={() => onvoice?.()}
|
|
285
287
|
disabled={resolvedVoiceDisabled}
|
|
286
288
|
ariaLabel={voiceLabel}
|
|
@@ -429,6 +431,8 @@
|
|
|
429
431
|
--button-text-color: var(--chat-composer-action-color, #52525b);
|
|
430
432
|
--button-content-gap: 0px;
|
|
431
433
|
--button-hover-color: var(--chat-composer-action-hover-background-color, #f4f4f5);
|
|
434
|
+
/* Ghost brings its own hover label; keep this control's, recording red included. */
|
|
435
|
+
--button-hover-text-color: var(--button-text-color);
|
|
432
436
|
}
|
|
433
437
|
|
|
434
438
|
.voice.recording {
|
|
@@ -57,7 +57,11 @@
|
|
|
57
57
|
visible text of its own. Keeping it once the label swaps to
|
|
58
58
|
"Copied!" would leave a screen reader announcing "Copy to clipboard"
|
|
59
59
|
over the visible feedback (WCAG 2.5.3, Label in Name). -->
|
|
60
|
-
<Button
|
|
60
|
+
<Button
|
|
61
|
+
variant="ghost"
|
|
62
|
+
onclick={handleCopy}
|
|
63
|
+
{...copyState.copied ? {} : { ariaLabel: 'Copy to clipboard' }}
|
|
64
|
+
>
|
|
61
65
|
{#if copyState.copied}
|
|
62
66
|
<span class="snippet-copied">{copiedLabel}</span>
|
|
63
67
|
{:else if typeof copyIcon === 'function'}
|
|
@@ -124,6 +128,8 @@
|
|
|
124
128
|
--button-border-radius: var(--snippet-copy-border-radius, var(--radius, 4px));
|
|
125
129
|
--cursor: var(--snippet-copy-cursor, pointer);
|
|
126
130
|
--button-hover-color: var(--snippet-copy-hover-background, #333);
|
|
131
|
+
/* Ghost brings its own hover label; keep this button's. */
|
|
132
|
+
--button-hover-text-color: var(--button-text-color);
|
|
127
133
|
display: flex;
|
|
128
134
|
align-items: center;
|
|
129
135
|
justify-content: center;
|
package/dist/Table/Table.svelte
CHANGED
|
@@ -0,0 +1,344 @@
|
|
|
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. `dark` is `$state` for the same reason `draw` below reads it
|
|
65
|
+
* synchronously inside the main `$effect` -- writing it must retrigger
|
|
66
|
+
* that effect, otherwise a theme flip while the loop is stopped (paused,
|
|
67
|
+
* or reduced motion, which paints exactly once) would leave the ink ramp
|
|
68
|
+
* on the wrong theme for as long as the orb stays still. `pointer` is a
|
|
69
|
+
* plain `let`, not `$state`: a still orb repaints explicitly on pointer
|
|
70
|
+
* events (see `repaintIfStill`), and a running orb's loop already reads
|
|
71
|
+
* the latest pointer every tick, so retriggering the whole effect on every
|
|
72
|
+
* pointer move would only tear down and restart the loop for nothing.
|
|
73
|
+
*/
|
|
74
|
+
let reducedMotion = $state(prefersReducedMotion());
|
|
75
|
+
let onScreen = $state(true);
|
|
76
|
+
let tabVisible = $state(true);
|
|
77
|
+
let dark = $state(false);
|
|
78
|
+
let announcedFirstFrame = false;
|
|
79
|
+
let resolvedColorInput = '';
|
|
80
|
+
let resolvedTint: Rgb = FALLBACK_TINT;
|
|
81
|
+
let pointer: GravityPointer = null;
|
|
82
|
+
let animationHandle = 0;
|
|
83
|
+
/** The orb's own clock: speed-scaled seconds, starting at 0 on mount and
|
|
84
|
+
* advancing only while the loop actually runs (see the main `$effect`). A
|
|
85
|
+
* still orb simply stops touching it, so it holds the exact instant it
|
|
86
|
+
* stopped at with no extra bookkeeping. */
|
|
87
|
+
let clock = 0;
|
|
88
|
+
|
|
89
|
+
const shouldRun = $derived(!paused && !reducedMotion && onScreen && tabVisible);
|
|
90
|
+
|
|
91
|
+
/** What a still orb paints: the fixed instant under reduced motion, else
|
|
92
|
+
* whatever `clock` last reached. */
|
|
93
|
+
const heldInstant = (): number => (reducedMotion ? REDUCED_MOTION_INSTANT : clock);
|
|
94
|
+
|
|
95
|
+
/*
|
|
96
|
+
* Assigning an unparseable colour to `fillStyle` is a spec no-op, so the
|
|
97
|
+
* candidate is tried against two different backgrounds and the results
|
|
98
|
+
* compared -- agreement means the browser accepted and normalised it (see
|
|
99
|
+
* VoiceOrb.svelte, which uses the same trick).
|
|
100
|
+
*/
|
|
101
|
+
const normalizeColor = (context: CanvasRenderingContext2D, value: string): string | null => {
|
|
102
|
+
if (value.length === 0) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const previous = context.fillStyle;
|
|
106
|
+
context.fillStyle = '#000000';
|
|
107
|
+
context.fillStyle = value;
|
|
108
|
+
const onBlack = context.fillStyle;
|
|
109
|
+
context.fillStyle = '#ffffff';
|
|
110
|
+
context.fillStyle = value;
|
|
111
|
+
const onWhite = context.fillStyle;
|
|
112
|
+
context.fillStyle = previous;
|
|
113
|
+
return typeof onBlack === 'string' && onBlack === onWhite ? onBlack : null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const readColorInput = (
|
|
117
|
+
target: HTMLCanvasElement
|
|
118
|
+
): { declared: string; inherited: string; key: string } => {
|
|
119
|
+
const styles = getComputedStyle(target);
|
|
120
|
+
const declared = styles.getPropertyValue('--sui-thinking-orb-color').trim();
|
|
121
|
+
const inherited = styles.color.trim();
|
|
122
|
+
return { declared, inherited, key: `${declared}|${inherited}` };
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** `color` prop wins outright; otherwise re-read via getComputedStyle, cached by the raw input string. */
|
|
126
|
+
const resolveTint = (context: CanvasRenderingContext2D): Rgb => {
|
|
127
|
+
if (color) {
|
|
128
|
+
return color;
|
|
129
|
+
}
|
|
130
|
+
if (canvas === null) {
|
|
131
|
+
return resolvedTint;
|
|
132
|
+
}
|
|
133
|
+
const { declared, inherited, key } = readColorInput(canvas);
|
|
134
|
+
if (key === resolvedColorInput) {
|
|
135
|
+
return resolvedTint;
|
|
136
|
+
}
|
|
137
|
+
resolvedColorInput = key;
|
|
138
|
+
const normalized = normalizeColor(context, declared) ?? normalizeColor(context, inherited);
|
|
139
|
+
resolvedTint = (normalized === null ? null : parseCssColor(normalized)) ?? FALLBACK_TINT;
|
|
140
|
+
return resolvedTint;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** A still orb has no running loop to pick up a moved pointer on its own,
|
|
144
|
+
* so gravity repaints it directly; a running orb's loop already paints the
|
|
145
|
+
* latest pointer every tick and needs no help here. */
|
|
146
|
+
const repaintIfStill = (): void => {
|
|
147
|
+
if (shouldRun || gravityOptions === null) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
draw(heldInstant());
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const trackPointer = (event: PointerEvent): void => {
|
|
154
|
+
if (canvas === null || !gravity) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const rect = canvas.getBoundingClientRect();
|
|
158
|
+
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
159
|
+
repaintIfStill();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const releasePointer = (): void => {
|
|
163
|
+
pointer = null;
|
|
164
|
+
repaintIfStill();
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** SSR renders with no `window`; the backing store is left at 1x until the
|
|
168
|
+
* component actually mounts in a browser and can ask it for a real ratio. */
|
|
169
|
+
const resolveBackingScale = (): number => {
|
|
170
|
+
if (typeof window === 'undefined') {
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
return Math.min(MAX_BACKING_SCALE, window.devicePixelRatio ?? 1);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Paints one instant. `instant` is already speed-scaled seconds -- either
|
|
177
|
+
* the orb's own running `clock` or the fixed reduced-motion instant. Safe
|
|
178
|
+
* to call whether or not the loop is running. */
|
|
179
|
+
const draw = (instant: number): void => {
|
|
180
|
+
if (canvas === null) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const context = canvas.getContext('2d');
|
|
184
|
+
if (context === null) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const dpr = resolveBackingScale();
|
|
189
|
+
const backingPx = Math.round(safeSize * dpr);
|
|
190
|
+
if (canvas.width !== backingPx || canvas.height !== backingPx) {
|
|
191
|
+
canvas.width = backingPx;
|
|
192
|
+
canvas.height = backingPx;
|
|
193
|
+
}
|
|
194
|
+
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
195
|
+
|
|
196
|
+
const frame = MODES[safeState](instant, { size: safeSize, density, dotScale });
|
|
197
|
+
const gOpts = gravityOptions;
|
|
198
|
+
const painted = gOpts === null ? frame : attract(frame, pointer, gOpts);
|
|
199
|
+
paintFrame(context, painted, { dark, tint: resolveTint(context) });
|
|
200
|
+
|
|
201
|
+
if (!announcedFirstFrame) {
|
|
202
|
+
announcedFirstFrame = true;
|
|
203
|
+
onfirstframe?.();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/*
|
|
208
|
+
* Re-runs whenever `shouldRun` or anything the leading `draw` reads changes
|
|
209
|
+
* (`safeState`, `safeSize`, `density`, `dotScale`, `gravityOptions`, `dark`,
|
|
210
|
+
* `color`, `reducedMotion`, ...) -- Svelte tracks every reactive read inside
|
|
211
|
+
* this body, including transitively through that synchronous `draw` call,
|
|
212
|
+
* so a prop change tears down the previous rAF (the cleanup below) and
|
|
213
|
+
* repaints from scratch with no dependency list to maintain by hand.
|
|
214
|
+
*
|
|
215
|
+
* `clock` itself is never read synchronously here, only inside the `loop`
|
|
216
|
+
* closure below, which runs later from `requestAnimationFrame` -- so
|
|
217
|
+
* advancing it never retriggers this effect. That's what lets `speed`
|
|
218
|
+
* change the clock's rate starting next tick with no jump and no loop
|
|
219
|
+
* restart: `loop` reads the live `speed` prop itself, every tick.
|
|
220
|
+
*/
|
|
221
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
222
|
+
$effect(() => {
|
|
223
|
+
// A still orb (paused, reduced motion or off screen) never touches
|
|
224
|
+
// `clock`, so it simply holds whatever instant it last reached -- no
|
|
225
|
+
// separate "was it frozen" bookkeeping needed. At least one frame is
|
|
226
|
+
// always painted, so the canvas is never blank.
|
|
227
|
+
draw(heldInstant());
|
|
228
|
+
if (!shouldRun) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// Reset on every (re)start of the loop, including a resume from
|
|
232
|
+
// stillness: the first tick after a (re)start must not count the time
|
|
233
|
+
// that passed before it, so it advances `clock` by zero and every tick
|
|
234
|
+
// after that measures real elapsed time again.
|
|
235
|
+
let previousFrameTime: number | null = null;
|
|
236
|
+
const loop = (frameTime: number): void => {
|
|
237
|
+
const deltaSeconds = previousFrameTime === null ? 0 : (frameTime - previousFrameTime) / 1000;
|
|
238
|
+
previousFrameTime = frameTime;
|
|
239
|
+
clock += deltaSeconds * speed;
|
|
240
|
+
draw(clock);
|
|
241
|
+
animationHandle = requestAnimationFrame(loop);
|
|
242
|
+
};
|
|
243
|
+
animationHandle = requestAnimationFrame(loop);
|
|
244
|
+
return () => cancelAnimationFrame(animationHandle);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
onMount(() => {
|
|
248
|
+
if (canvas === null) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Every optional platform API below follows the same shape: build it
|
|
253
|
+
// when the browser exposes the constructor/method it needs, otherwise
|
|
254
|
+
// leave the binding `null` so the optional-chaining calls below no-op.
|
|
255
|
+
let intersectionObserver: IntersectionObserver | null = null;
|
|
256
|
+
if (typeof IntersectionObserver !== 'undefined') {
|
|
257
|
+
intersectionObserver = new IntersectionObserver(([entry]) => {
|
|
258
|
+
onScreen = entry.isIntersecting;
|
|
259
|
+
});
|
|
260
|
+
intersectionObserver.observe(canvas);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const onVisibilityChange = (): void => {
|
|
264
|
+
tabVisible = document.visibilityState !== 'hidden';
|
|
265
|
+
};
|
|
266
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
267
|
+
|
|
268
|
+
/*
|
|
269
|
+
* This repo's `ThemeSwitcher` stamps `data-theme="dark"` on
|
|
270
|
+
* `document.documentElement` -- one attribute on one element -- so a
|
|
271
|
+
* `MutationObserver` there alone catches every theme change. It exists
|
|
272
|
+
* only to keep `paintFrame`'s `dark` argument current: the tint colour
|
|
273
|
+
* itself comes from CSS, resolved in `resolveTint` above.
|
|
274
|
+
*/
|
|
275
|
+
const readTheme = (): void => {
|
|
276
|
+
dark = document.documentElement.getAttribute('data-theme') === 'dark';
|
|
277
|
+
};
|
|
278
|
+
readTheme();
|
|
279
|
+
let themeObserver: MutationObserver | null = null;
|
|
280
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
281
|
+
themeObserver = new MutationObserver(readTheme);
|
|
282
|
+
themeObserver.observe(document.documentElement, {
|
|
283
|
+
attributes: true,
|
|
284
|
+
attributeFilter: ['data-theme']
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const onMotionChange = (event: MediaQueryListEvent): void => {
|
|
289
|
+
reducedMotion = event.matches;
|
|
290
|
+
};
|
|
291
|
+
let motionQuery: MediaQueryList | null = null;
|
|
292
|
+
if (typeof window.matchMedia === 'function') {
|
|
293
|
+
motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
294
|
+
motionQuery.addEventListener('change', onMotionChange);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/*
|
|
298
|
+
* A still orb only repaints on its own when `dark` flips. A host that
|
|
299
|
+
* transitions its `color` (this repo's own layout fades `body` across a
|
|
300
|
+
* theme switch) leaves a just-frozen frame at the outgoing colour, with
|
|
301
|
+
* nothing else to redraw it. Re-read once the transition lands and
|
|
302
|
+
* repaint the same frozen instant if the resolved colour actually moved.
|
|
303
|
+
* A running orb re-reads every frame already and needs none of this.
|
|
304
|
+
*/
|
|
305
|
+
const onColorTransitionEnd = (event: TransitionEvent): void => {
|
|
306
|
+
if (shouldRun || color || canvas === null) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (event.propertyName !== 'color' && event.propertyName !== '--sui-thinking-orb-color') {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (readColorInput(canvas).key !== resolvedColorInput) {
|
|
313
|
+
draw(heldInstant());
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
document.addEventListener('transitionend', onColorTransitionEnd);
|
|
317
|
+
|
|
318
|
+
return () => {
|
|
319
|
+
intersectionObserver?.disconnect();
|
|
320
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
321
|
+
document.removeEventListener('transitionend', onColorTransitionEnd);
|
|
322
|
+
themeObserver?.disconnect();
|
|
323
|
+
motionQuery?.removeEventListener('change', onMotionChange);
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
</script>
|
|
327
|
+
|
|
328
|
+
<!-- The canvas is a picture with a label, not a control: the pointer handlers
|
|
329
|
+
only feed the optional gravity effect and never act, so role="img" is
|
|
330
|
+
accurate. -->
|
|
331
|
+
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
|
|
332
|
+
<canvas
|
|
333
|
+
bind:this={canvas}
|
|
334
|
+
role="img"
|
|
335
|
+
aria-label={ariaLabel ?? `${safeState.charAt(0).toUpperCase()}${safeState.slice(1)}…`}
|
|
336
|
+
class={classes}
|
|
337
|
+
data-pw={typeof testId === 'string' ? testId : null}
|
|
338
|
+
style:width={`${safeSize}px`}
|
|
339
|
+
style:height={`${safeSize}px`}
|
|
340
|
+
style:display="block"
|
|
341
|
+
onpointermove={trackPointer}
|
|
342
|
+
onpointerleave={releasePointer}
|
|
343
|
+
onpointercancel={releasePointer}
|
|
344
|
+
></canvas>
|
|
@@ -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;
|