solid-drift 0.7.1 → 0.8.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/web3.js ADDED
@@ -0,0 +1,640 @@
1
+ /**
2
+ * web3 family: motion primitives for onchain product UI.
3
+ *
4
+ * Transaction lifecycle, price tickers, mint reveals, and wallet
5
+ * button micro-interactions, without depending on any wallet or chain
6
+ * library. State arrives through accessors in a wagmi/viem-style
7
+ * shape (the adapter pattern), so the primitives stay zero-dependency
8
+ * while your app keeps its own stack.
9
+ *
10
+ * All primitives are signal-native, SSR-safe, dependency-free, and
11
+ * define sensible static behavior under `prefers-reduced-motion`.
12
+ */
13
+ import { createEffect, createSignal, onCleanup, untrack, } from "solid-js";
14
+ import { animate } from "./animate.js";
15
+ import { createSquashStretch } from "./cartoon.js";
16
+ import { createAnticipation } from "./cartoon.js";
17
+ import { resolveEasing } from "./easing.js";
18
+ import { now, schedule } from "./engine.js";
19
+ import { createMagnetic } from "./pointer.js";
20
+ import { prefersReducedMotion } from "./reduced-motion.js";
21
+ import { createSpring } from "./spring.js";
22
+ import { ownerDoc } from "./text.js";
23
+ function clamp01(v) {
24
+ return v < 0 ? 0 : v > 1 ? 1 : v;
25
+ }
26
+ /** Round to 2 decimals for style values. */
27
+ function fmt(v) {
28
+ return String(Math.round(v * 100) / 100);
29
+ }
30
+ const STATE_PROGRESS = {
31
+ idle: 0,
32
+ signing: 0.25,
33
+ pending: 0.5,
34
+ confirming: 0.75,
35
+ success: 1,
36
+ failed: 1,
37
+ };
38
+ /**
39
+ * Signal-native transaction lifecycle.
40
+ *
41
+ * Feed it wagmi/viem-style state through `source` and it derives the
42
+ * stage: a reported hash with too few confirmations is "pending",
43
+ * enough confirmations is "confirming", success/error map to
44
+ * "success"/"failed". Stages the chain never reports, like "signing"
45
+ * while the wallet prompt is open, are set manually.
46
+ *
47
+ * ```ts
48
+ * const tx = createTxLifecycle({
49
+ * source: () => ({
50
+ * status: receiptQuery.status, // "pending" | "success" | "error" | "idle"
51
+ * confirmations: receiptQuery.confirmations,
52
+ * }),
53
+ * requiredConfirmations: 2,
54
+ * })
55
+ * const openWallet = () => {
56
+ * tx.set("signing")
57
+ * sendTransaction()
58
+ * }
59
+ * // <ProgressRing value={tx.progress()} state={tx.state()} />
60
+ * ```
61
+ *
62
+ * SSR-safe: "idle" with `progress()` 0. Under reduced motion state
63
+ * changes apply instantly and `progress()` jumps to its target.
64
+ */
65
+ export function createTxLifecycle(options = {}) {
66
+ const { source, requiredConfirmations = 1, stiffness = 170, damping = 26, onEnter, } = options;
67
+ const [state, setState] = createSignal("idle");
68
+ const [target, setTarget] = createSignal(0);
69
+ const progress = createSpring(target, { stiffness, damping });
70
+ const apply = (next) => {
71
+ const current = state();
72
+ if (next === current)
73
+ return;
74
+ setState(next);
75
+ setTarget(STATE_PROGRESS[next]);
76
+ onEnter?.(next, current);
77
+ };
78
+ const derive = (input) => {
79
+ if (input.status === "success")
80
+ return "success";
81
+ if (input.status === "error")
82
+ return "failed";
83
+ if (input.status === "pending") {
84
+ return (input.confirmations ?? 0) >= requiredConfirmations
85
+ ? "confirming"
86
+ : "pending";
87
+ }
88
+ return "idle";
89
+ };
90
+ if (source) {
91
+ createEffect(() => {
92
+ const next = derive(source());
93
+ // Apply untracked: reading state() inside apply() must not make this
94
+ // effect depend on state, or a manual set() would be undone by a
95
+ // re-derive from the unchanged source.
96
+ untrack(() => apply(next));
97
+ });
98
+ }
99
+ return {
100
+ state,
101
+ set: apply,
102
+ reset: () => apply("idle"),
103
+ progress,
104
+ };
105
+ }
106
+ /**
107
+ * Animated price/balance ticker: per-digit roll, direction flash.
108
+ *
109
+ * Each digit is a 0-9 strip in an overflow-hidden column, driven by a
110
+ * single rAF task for the whole ticker. Rapid source updates batch to
111
+ * one render per frame, keeping only the latest value, so a hot price
112
+ * feed never queues animation debt. Non-digit characters (decimal
113
+ * point, grouping separators, minus sign) render statically.
114
+ *
115
+ * ```tsx
116
+ * let el!: HTMLSpanElement
117
+ * const [price, setPrice] = createSignal(48210.5)
118
+ * const ticker = createTicker(price, () => el, { decimals: 2 })
119
+ * <span ref={el} style={{ color: ticker.direction() === "up" ? "green" : "red" }}>
120
+ * {ticker.display()}
121
+ * </span>
122
+ * ```
123
+ *
124
+ * SSR-safe: `display()` returns the formatted string with no DOM.
125
+ * Under reduced motion the text swaps instantly: no roll, no flash.
126
+ */
127
+ export function createTicker(source, ref, options = {}) {
128
+ const { decimals = 2, duration = 400, upColor = "#16a34a", downColor = "#dc2626", flashMs = 600, locale = "en-US", } = options;
129
+ const server = typeof window === "undefined";
130
+ const easing = resolveEasing("easeOutCubic");
131
+ const format = (v) => new Intl.NumberFormat(locale, {
132
+ minimumFractionDigits: decimals,
133
+ maximumFractionDigits: decimals,
134
+ }).format(v);
135
+ const initial = untrack(source);
136
+ const [display, setDisplay] = createSignal(format(initial));
137
+ const [direction, setDirection] = createSignal("flat");
138
+ let prevValue = initial;
139
+ let rendered = "";
140
+ let strips = [];
141
+ let scheduled = false;
142
+ let renderCancel = null;
143
+ let flashStop = null;
144
+ let driveCancel = null;
145
+ let anims = [];
146
+ const isDigit = (ch) => ch >= "0" && ch <= "9";
147
+ /** One rAF task drives every rolling digit. */
148
+ const drive = () => {
149
+ if (driveCancel || anims.length === 0 || server)
150
+ return;
151
+ driveCancel = schedule((t) => {
152
+ let alive = false;
153
+ anims = anims.filter((a) => {
154
+ const local = clamp01((t - a.startAt) / duration);
155
+ const v = a.from + (a.to - a.from) * easing(local);
156
+ a.strip.style.transform = `translateY(${fmt(-v * 10)}%)`;
157
+ if (local < 1)
158
+ alive = true;
159
+ return local < 1;
160
+ });
161
+ if (!alive)
162
+ driveCancel = null;
163
+ return alive;
164
+ });
165
+ };
166
+ const flash = (color) => {
167
+ const el = ref();
168
+ if (!el)
169
+ return;
170
+ flashStop?.();
171
+ el.style.color = color;
172
+ const timer = animate(0, 1, {
173
+ duration: Math.max(flashMs, 1),
174
+ onComplete: () => {
175
+ el.style.color = "";
176
+ },
177
+ });
178
+ flashStop = timer.stop;
179
+ };
180
+ const makeStrip = (doc, digit) => {
181
+ const wrap = doc.createElement("span");
182
+ wrap.style.display = "inline-block";
183
+ wrap.style.overflow = "hidden";
184
+ wrap.style.verticalAlign = "top";
185
+ const strip = doc.createElement("span");
186
+ strip.style.display = "block";
187
+ strip.style.willChange = "transform";
188
+ strip.style.transform = `translateY(${-digit * 10}%)`;
189
+ for (let d = 0; d <= 9; d++) {
190
+ const cell = doc.createElement("span");
191
+ cell.textContent = String(d);
192
+ cell.style.display = "block";
193
+ cell.setAttribute("aria-hidden", "true");
194
+ strip.appendChild(cell);
195
+ }
196
+ wrap.appendChild(strip);
197
+ return wrap;
198
+ };
199
+ const stripOf = (wrap) => wrap.firstChild ?? null;
200
+ const render = () => {
201
+ const value = untrack(source);
202
+ const formatted = format(value);
203
+ setDisplay(formatted);
204
+ const dir = value > prevValue ? "up" : value < prevValue ? "down" : "flat";
205
+ prevValue = value;
206
+ setDirection(dir);
207
+ if (dir !== "flat" && !prefersReducedMotion()) {
208
+ flash(dir === "up" ? upColor : downColor);
209
+ }
210
+ const el = ref();
211
+ if (!el || server)
212
+ return;
213
+ const doc = ownerDoc(el);
214
+ if (!doc)
215
+ return;
216
+ if (prefersReducedMotion()) {
217
+ // Accessibility: swap the text instantly, no roll, no flash.
218
+ el.textContent = formatted;
219
+ rendered = formatted;
220
+ strips = [];
221
+ return;
222
+ }
223
+ const chars = [...formatted];
224
+ const prevChars = [...rendered];
225
+ const maskChanged = rendered === "" ||
226
+ chars.length !== prevChars.length ||
227
+ chars.some((ch, i) => isDigit(ch) !== isDigit(prevChars[i]));
228
+ if (maskChanged) {
229
+ el.textContent = "";
230
+ strips = [];
231
+ for (const ch of chars) {
232
+ if (isDigit(ch)) {
233
+ const wrap = makeStrip(doc, Number(ch));
234
+ el.appendChild(wrap);
235
+ strips.push(stripOf(wrap));
236
+ }
237
+ else {
238
+ const s = doc.createElement("span");
239
+ s.textContent = ch;
240
+ el.appendChild(s);
241
+ strips.push(null);
242
+ }
243
+ }
244
+ rendered = formatted;
245
+ return;
246
+ }
247
+ // Same layout: roll only the digits that changed.
248
+ const t = now();
249
+ chars.forEach((ch, i) => {
250
+ if (!isDigit(ch) || ch === prevChars[i])
251
+ return;
252
+ const strip = strips[i];
253
+ if (!strip)
254
+ return;
255
+ anims = anims.filter((a) => a.strip !== strip);
256
+ anims.push({
257
+ strip,
258
+ from: Number(prevChars[i]),
259
+ to: Number(ch),
260
+ startAt: t,
261
+ });
262
+ });
263
+ rendered = formatted;
264
+ drive();
265
+ };
266
+ /** Batch rapid updates: at most one render per frame, latest wins. */
267
+ const requestRender = () => {
268
+ if (scheduled || server)
269
+ return;
270
+ scheduled = true;
271
+ renderCancel?.();
272
+ renderCancel = schedule(() => {
273
+ scheduled = false;
274
+ renderCancel = null;
275
+ render();
276
+ return false;
277
+ });
278
+ };
279
+ if (!server) {
280
+ createEffect(() => {
281
+ source(); // track
282
+ requestRender();
283
+ });
284
+ }
285
+ onCleanup(() => {
286
+ renderCancel?.();
287
+ renderCancel = null;
288
+ driveCancel?.();
289
+ driveCancel = null;
290
+ flashStop?.();
291
+ flashStop = null;
292
+ });
293
+ return { display, direction };
294
+ }
295
+ /**
296
+ * Pack-open / card-reveal choreography, built on the cartoon family:
297
+ * an anticipation shake, a rotateY flip with a face swap at the
298
+ * midpoint, and squash and stretch driven by the flip's own velocity
299
+ * as the card lands. Everything stays on compositor-friendly
300
+ * properties (transform, and the CSS `scale` property for the deform).
301
+ *
302
+ * ```tsx
303
+ * let card!: HTMLDivElement
304
+ * const [face, setFace] = createSignal<"back" | "front">("back")
305
+ * const reveal = createMintReveal(() => card, {
306
+ * onFlip: () => setFace("front"), // swap faces mid-flip
307
+ * })
308
+ * <button onClick={() => reveal.play()}>Reveal</button>
309
+ * <div ref={card} style={{ "backface-visibility": "hidden" }}>
310
+ * {face() === "back" ? <CardBack /> : <NftFront />}
311
+ * </div>
312
+ * ```
313
+ *
314
+ * SSR-safe: no-op on the server, `status()` is "revealed" so the face
315
+ * renders statically. Under reduced motion the shake and flip are
316
+ * skipped and the final face shows at once.
317
+ */
318
+ export function createMintReveal(ref, options = {}) {
319
+ const { shakeDuration = 500, flipDuration = 700, squash = true, easing: easingOpt = "easeOutCubic", onFlip, } = options;
320
+ const easing = resolveEasing(easingOpt);
321
+ const server = typeof window === "undefined";
322
+ const [status, setStatus] = createSignal(server ? "revealed" : "idle");
323
+ const [flipP, setFlipP] = createSignal(0);
324
+ let stopCurrent = null;
325
+ let runToken = 0;
326
+ if (squash) {
327
+ // The flip's own progress is the motion source: the card stretches
328
+ // mid-flip and squashes as it lands. Self-guards SSR and reduced
329
+ // motion, where it applies no deform.
330
+ createSquashStretch(ref, {
331
+ source: flipP,
332
+ fullSpeed: 400,
333
+ impactThreshold: 1200,
334
+ });
335
+ }
336
+ const play = () => {
337
+ const token = ++runToken;
338
+ stopCurrent?.();
339
+ stopCurrent = null;
340
+ if (server) {
341
+ setStatus("revealed");
342
+ return Promise.resolve();
343
+ }
344
+ const el = ref();
345
+ if (!el) {
346
+ setStatus("revealed");
347
+ return Promise.resolve();
348
+ }
349
+ if (prefersReducedMotion()) {
350
+ // Accessibility: skip the shake and the flip.
351
+ onFlip?.();
352
+ setFlipP(180);
353
+ setStatus("revealed");
354
+ return Promise.resolve();
355
+ }
356
+ setStatus("anticipating");
357
+ return (async () => {
358
+ // Anticipation shake: wind up before the flip fires.
359
+ // Await `finished` (not onComplete): it resolves when the animation
360
+ // is stopped too, so reset() mid-play cannot strand this promise.
361
+ if (token === runToken) {
362
+ const ctl = createAnticipation(0, 0, {
363
+ windup: 14,
364
+ windupDuration: Math.max(shakeDuration * 0.55, 1),
365
+ holdDuration: Math.max(shakeDuration * 0.15, 0),
366
+ duration: Math.max(shakeDuration * 0.3, 1),
367
+ onUpdate: (v) => {
368
+ if (token === runToken) {
369
+ el.style.transform = `translateX(${fmt(v)}px)`;
370
+ }
371
+ },
372
+ });
373
+ stopCurrent = ctl.stop;
374
+ await ctl.finished;
375
+ }
376
+ if (token !== runToken)
377
+ return;
378
+ // The flip. onFlip fires at the midpoint, while the card is
379
+ // edge-on and the face swap is invisible.
380
+ setStatus("flipping");
381
+ let flipped = false;
382
+ const flip = animate(0, 180, {
383
+ duration: Math.max(flipDuration, 1),
384
+ easing,
385
+ onUpdate: (v) => {
386
+ if (token !== runToken)
387
+ return;
388
+ setFlipP(v);
389
+ el.style.transform = `rotateY(${fmt(v)}deg)`;
390
+ if (!flipped && v >= 90) {
391
+ flipped = true;
392
+ onFlip?.();
393
+ }
394
+ },
395
+ });
396
+ stopCurrent = flip.stop;
397
+ await flip.finished;
398
+ stopCurrent = null;
399
+ if (token !== runToken)
400
+ return;
401
+ setFlipP(180);
402
+ el.style.transform = "rotateY(180deg)";
403
+ setStatus("revealed");
404
+ })();
405
+ };
406
+ const reset = () => {
407
+ runToken++;
408
+ stopCurrent?.();
409
+ stopCurrent = null;
410
+ setFlipP(0);
411
+ if (!server) {
412
+ const el = ref();
413
+ if (el)
414
+ el.style.transform = "";
415
+ setStatus("idle");
416
+ }
417
+ };
418
+ onCleanup(() => {
419
+ runToken++;
420
+ stopCurrent?.();
421
+ stopCurrent = null;
422
+ });
423
+ return { play, reset, status };
424
+ }
425
+ /**
426
+ * Wallet connect-button micro-interactions: magnetic hover pull,
427
+ * press scale, a checkmark tick for copy-address feedback, and an
428
+ * expanding ring pulse for chain switches.
429
+ *
430
+ * The button's transform is owned by the primitive (magnetic pull
431
+ * plus press scale); the tick and pulse animate overlay spans so
432
+ * they never fight the hover motion. The button is given
433
+ * `position: relative` the first time a tick or pulse runs, to stage
434
+ * the overlays.
435
+ *
436
+ * ```tsx
437
+ * let btn!: HTMLButtonElement
438
+ * const connect = createConnectButton(() => btn)
439
+ * const copy = async () => {
440
+ * await navigator.clipboard.writeText(address())
441
+ * connect.copyTick()
442
+ * }
443
+ * <button ref={btn} onClick={copy}>0x7a…f3c2</button>
444
+ * ```
445
+ *
446
+ * SSR-safe: no-op on the server. Under reduced motion the magnetic
447
+ * pull is off and the tick/pulse become instant state changes (the
448
+ * check shows statically, then hides).
449
+ */
450
+ export function createConnectButton(ref, options = {}) {
451
+ const { strength = 0.35, pressScale = 0.96 } = options;
452
+ const server = typeof window === "undefined";
453
+ const staticMode = server || prefersReducedMotion();
454
+ const [status, setStatus] = createSignal("idle");
455
+ const [pressed, setPressed] = createSignal(false);
456
+ const magnetic = createMagnetic(ref, { strength });
457
+ let checkEl = null;
458
+ let ringEl = null;
459
+ let tickStop = null;
460
+ let pulseStop = null;
461
+ let tickToken = 0;
462
+ let pulseToken = 0;
463
+ const targets = (el, target) => {
464
+ if (target === el)
465
+ return true;
466
+ const contains = el.contains;
467
+ return (typeof contains === "function" && contains.call(el, target) === true);
468
+ };
469
+ if (!server && !staticMode) {
470
+ const onDown = (e) => {
471
+ const el = ref();
472
+ if (el && targets(el, e.target))
473
+ setPressed(true);
474
+ };
475
+ const onUp = () => {
476
+ setPressed(false);
477
+ };
478
+ const w = window;
479
+ w.addEventListener("pointerdown", onDown);
480
+ w.addEventListener("pointerup", onUp);
481
+ w.addEventListener("pointercancel", onUp);
482
+ onCleanup(() => {
483
+ w.removeEventListener("pointerdown", onDown);
484
+ w.removeEventListener("pointerup", onUp);
485
+ w.removeEventListener("pointercancel", onUp);
486
+ });
487
+ // The primitive owns the button's transform: magnetic pull plus
488
+ // press scale. Tick and pulse use overlays, never the transform.
489
+ createEffect(() => {
490
+ const el = ref();
491
+ if (!el)
492
+ return;
493
+ const s = pressed() ? pressScale : 1;
494
+ el.style.transform =
495
+ `translate(${fmt(magnetic.x())}px, ${fmt(magnetic.y())}px) scale(${fmt(s)})`;
496
+ });
497
+ }
498
+ /** Stage the button for absolutely-positioned overlays. */
499
+ const stage = () => {
500
+ const el = ref();
501
+ if (!el || server)
502
+ return null;
503
+ const doc = ownerDoc(el);
504
+ if (!doc)
505
+ return null;
506
+ if (el.style.position !== "relative" && el.style.position !== "absolute") {
507
+ el.style.position = "relative";
508
+ }
509
+ return { host: el, doc };
510
+ };
511
+ const getCheck = (host, doc) => {
512
+ if (!checkEl) {
513
+ checkEl = doc.createElement("span");
514
+ checkEl.textContent = "✓";
515
+ checkEl.setAttribute("aria-hidden", "true");
516
+ const s = checkEl.style;
517
+ s.position = "absolute";
518
+ s.inset = "0";
519
+ s.display = "flex";
520
+ s.alignItems = "center";
521
+ s.justifyContent = "center";
522
+ s.fontSize = "1.25em";
523
+ s.pointerEvents = "none";
524
+ s.opacity = "0";
525
+ host.appendChild(checkEl);
526
+ }
527
+ return checkEl;
528
+ };
529
+ const getRing = (host, doc) => {
530
+ if (!ringEl) {
531
+ ringEl = doc.createElement("span");
532
+ ringEl.setAttribute("aria-hidden", "true");
533
+ const s = ringEl.style;
534
+ s.position = "absolute";
535
+ s.inset = "-2px";
536
+ s.borderRadius = "inherit";
537
+ s.pointerEvents = "none";
538
+ s.opacity = "0";
539
+ host.appendChild(ringEl);
540
+ }
541
+ return ringEl;
542
+ };
543
+ const copyTick = () => {
544
+ const staged = stage();
545
+ if (!staged)
546
+ return;
547
+ const token = ++tickToken;
548
+ tickStop?.();
549
+ tickStop = null;
550
+ setStatus("ticking");
551
+ const check = getCheck(staged.host, staged.doc);
552
+ if (staticMode) {
553
+ // Accessibility: show the check statically, then hide it.
554
+ check.style.transform = "none";
555
+ check.style.opacity = "1";
556
+ const timer = animate(0, 1, {
557
+ duration: 1200,
558
+ onComplete: () => {
559
+ if (token !== tickToken)
560
+ return;
561
+ check.style.opacity = "0";
562
+ setStatus("idle");
563
+ },
564
+ });
565
+ tickStop = timer.stop;
566
+ return;
567
+ }
568
+ check.style.opacity = "1";
569
+ const pop = animate(0, 1, {
570
+ duration: 280,
571
+ easing: "easeOutBack",
572
+ onUpdate: (v) => {
573
+ if (token !== tickToken)
574
+ return;
575
+ check.style.transform = `scale(${fmt(0.6 + 0.4 * v)})`;
576
+ check.style.opacity = fmt(Math.min(v * 2, 1));
577
+ },
578
+ onComplete: () => {
579
+ if (token !== tickToken)
580
+ return;
581
+ const fade = animate(1, 0, {
582
+ duration: 220,
583
+ onUpdate: (v) => {
584
+ check.style.opacity = fmt(v);
585
+ },
586
+ onComplete: () => {
587
+ if (token !== tickToken)
588
+ return;
589
+ setStatus("idle");
590
+ },
591
+ });
592
+ tickStop = fade.stop;
593
+ },
594
+ });
595
+ tickStop = pop.stop;
596
+ };
597
+ const chainPulse = () => {
598
+ const staged = stage();
599
+ if (!staged)
600
+ return;
601
+ const token = ++pulseToken;
602
+ pulseStop?.();
603
+ pulseStop = null;
604
+ setStatus("pulsing");
605
+ const ring = getRing(staged.host, staged.doc);
606
+ if (staticMode) {
607
+ // Accessibility: instant state change, no expanding ring.
608
+ ring.style.opacity = "0";
609
+ setStatus("idle");
610
+ return;
611
+ }
612
+ const wave = animate(0, 1, {
613
+ duration: 600,
614
+ easing: "easeOutCubic",
615
+ onUpdate: (v) => {
616
+ if (token !== pulseToken)
617
+ return;
618
+ ring.style.opacity = fmt(1 - v);
619
+ ring.style.boxShadow = `0 0 0 ${fmt(v * 14)}px currentColor`;
620
+ },
621
+ onComplete: () => {
622
+ if (token !== pulseToken)
623
+ return;
624
+ ring.style.opacity = "0";
625
+ ring.style.boxShadow = "none";
626
+ setStatus("idle");
627
+ },
628
+ });
629
+ pulseStop = wave.stop;
630
+ };
631
+ onCleanup(() => {
632
+ tickToken++;
633
+ pulseToken++;
634
+ tickStop?.();
635
+ tickStop = null;
636
+ pulseStop?.();
637
+ pulseStop = null;
638
+ });
639
+ return { copyTick, chainPulse, status };
640
+ }
package/package.json CHANGED
@@ -43,5 +43,5 @@
43
43
  },
44
44
  "type": "module",
45
45
  "types": "./dist/index.d.ts",
46
- "version": "0.7.1"
46
+ "version": "0.8.0"
47
47
  }