@bearmenu/ui 0.8.3 → 0.8.4

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.
Files changed (39) hide show
  1. package/dist/chunk-2WDJR6K7.js +76 -0
  2. package/dist/components/icon.d.ts +18 -0
  3. package/dist/components/icon.js +31 -0
  4. package/dist/components/motion-icon-controls.d.ts +26 -0
  5. package/dist/components/motion-icon-controls.js +380 -0
  6. package/dist/components/motion-icon-set.d.ts +27 -0
  7. package/dist/components/motion-icon-set.js +139 -0
  8. package/dist/components/motion-icon.d.ts +44 -0
  9. package/dist/components/motion-icon.js +3 -0
  10. package/dist/lib/icon-registry.d.ts +58 -0
  11. package/dist/lib/icon-registry.js +39 -0
  12. package/package.json +2 -1
  13. package/src/components/accordion.stories.tsx +1 -1
  14. package/src/components/combobox.stories.tsx +1 -1
  15. package/src/components/currency-input.stories.tsx +3 -3
  16. package/src/components/empty-state.stories.tsx +3 -3
  17. package/src/components/form.stories.tsx +1 -1
  18. package/src/components/icon.tsx +57 -0
  19. package/src/components/input-otp.stories.tsx +1 -1
  20. package/src/components/markdown-renderer.stories.tsx +1 -1
  21. package/src/components/motion-icon-controls.test.tsx +78 -0
  22. package/src/components/motion-icon-controls.tsx +552 -0
  23. package/src/components/motion-icon-set.stories.tsx +185 -0
  24. package/src/components/motion-icon-set.tsx +219 -0
  25. package/src/components/motion-icon.test.tsx +159 -0
  26. package/src/components/motion-icon.tsx +163 -0
  27. package/src/components/multi-select-combobox.stories.tsx +1 -1
  28. package/src/components/phone-frame.stories.tsx +1 -1
  29. package/src/components/rating.stories.tsx +3 -3
  30. package/src/components/searchable-select.stories.tsx +1 -1
  31. package/src/components/selection-bar.stories.tsx +1 -1
  32. package/src/components/skeleton-card.stories.tsx +3 -3
  33. package/src/components/sliding-panels.stories.tsx +1 -1
  34. package/src/components/stepper.stories.tsx +1 -1
  35. package/src/components/sticky-container.stories.tsx +3 -3
  36. package/src/components/toggle-group.stories.tsx +1 -1
  37. package/src/components/tooltip.stories.tsx +2 -2
  38. package/src/lib/icon-registry.ts +139 -0
  39. package/src/test/setup.ts +5 -1
@@ -0,0 +1,552 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Character-animated CONTROL glyphs — the toggles and one-shot actions.
5
+ *
6
+ * `motion-icon-set.tsx` holds glyphs that depict things (a house, a calendar).
7
+ * This file holds glyphs that depict controls (a heart you press, sliders you
8
+ * adjust). Same three-variant model, same amplitude and beat rules; see
9
+ * `motion-icon.tsx`.
10
+ *
11
+ * Geometry is vendored from lucide (ISC) on its 24-unit grid.
12
+ */
13
+
14
+ import * as React from "react";
15
+ import { motion, type Variants } from "framer-motion";
16
+
17
+ import { MotionIcon, beats, returnTransition, type MotionIconProps } from "./motion-icon";
18
+
19
+ // ─── Heart — favorite. Squash, then inflate (deform) ──────────────────────────
20
+
21
+ const HEART_TIMES = [0, 0.18, 0.5, 0.76, 1];
22
+
23
+ /**
24
+ * Classic squash and stretch: it compresses wide-and-flat first (anticipation),
25
+ * then inflates past its size and settles. The fill arrives on the inflate, so
26
+ * the colour change is the payoff of the swell rather than a separate event.
27
+ */
28
+ const heartBody: Variants = {
29
+ rest: { scaleX: 1, scaleY: 1, fillOpacity: 0, transition: returnTransition },
30
+ active: { scaleX: 1, scaleY: 1, fillOpacity: 1, transition: returnTransition },
31
+ play: {
32
+ scaleX: [1, 1.14, 0.9, 1.03, 1],
33
+ scaleY: [1, 0.82, 1.26, 0.96, 1],
34
+ fillOpacity: [0, 0, 1, 1, 1],
35
+ transition: beats(HEART_TIMES, 0.58),
36
+ },
37
+ };
38
+
39
+ export function MotionHeart(props: MotionIconProps) {
40
+ return (
41
+ <MotionIcon {...props}>
42
+ <motion.path
43
+ variants={heartBody}
44
+ style={{ originX: "12px", originY: "13px" }}
45
+ fill="currentColor"
46
+ fillOpacity={0}
47
+ d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"
48
+ />
49
+ </MotionIcon>
50
+ );
51
+ }
52
+
53
+ // ─── Bookmark — want to try. Drops in and tucks (fall and land) ───────────────
54
+
55
+ const BOOKMARK_TIMES = [0, 0.2, 0.56, 0.8, 1];
56
+
57
+ const bookmarkBody: Variants = {
58
+ rest: { y: 0, scaleY: 1, fillOpacity: 0, transition: returnTransition },
59
+ active: { y: 0, scaleY: 1, fillOpacity: 1, transition: returnTransition },
60
+ play: {
61
+ // Lifts clear, drops, compresses against the notch, settles filled.
62
+ y: [0, -7, 1.5, 0, 0],
63
+ scaleY: [1, 1.07, 0.88, 1.02, 1],
64
+ fillOpacity: [0, 0, 0, 1, 1],
65
+ transition: beats(BOOKMARK_TIMES, 0.6),
66
+ },
67
+ };
68
+
69
+ export function MotionBookmark(props: MotionIconProps) {
70
+ return (
71
+ <MotionIcon {...props}>
72
+ <motion.path
73
+ variants={bookmarkBody}
74
+ style={{ originX: "12px", originY: "21px" }}
75
+ fill="currentColor"
76
+ fillOpacity={0}
77
+ d="M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z"
78
+ />
79
+ </MotionIcon>
80
+ );
81
+ }
82
+
83
+ // ─── Sliders — filter. The handles travel their tracks (cycle content) ────────
84
+
85
+ const SLIDER_ROWS = [5, 12, 19];
86
+ /** Resting handle positions, matching lucide's rest silhouette. */
87
+ const HANDLE_X = [14, 8, 16];
88
+ /** Where each handle travels to and holds while the sheet is open. */
89
+ const HANDLE_ACTIVE_DX = [-6, 7, -4];
90
+
91
+ /**
92
+ * Deviation from stock lucide, deliberate: lucide breaks each track into two
93
+ * segments with a gap where the handle sits. A gap cannot move, so a sliding
94
+ * handle would leave it stranded. Tracks are drawn full-width here and the
95
+ * handles ride on top. At 22px the difference at rest is sub-pixel.
96
+ */
97
+ function sliderHandle(index: number): Variants {
98
+ const dx = HANDLE_ACTIVE_DX[index];
99
+ return {
100
+ rest: { x: 0, transition: returnTransition },
101
+ active: { x: dx, transition: returnTransition },
102
+ play: {
103
+ x: [0, -dx * 0.12, dx * 1.12, dx],
104
+ transition: beats([0, 0.16, 0.72, 1], 0.55 + index * 0.04),
105
+ },
106
+ };
107
+ }
108
+
109
+ export function MotionSliders(props: MotionIconProps) {
110
+ return (
111
+ <MotionIcon {...props}>
112
+ {SLIDER_ROWS.map((y) => (
113
+ <path key={`track-${y}`} d={`M3 ${y}h18`} opacity={0.45} />
114
+ ))}
115
+ {SLIDER_ROWS.map((y, i) => (
116
+ <motion.path key={`handle-${y}`} variants={sliderHandle(i)} d={`M${HANDLE_X[i]} ${y - 2}v4`} />
117
+ ))}
118
+ </MotionIcon>
119
+ );
120
+ }
121
+
122
+ // ─── Search — the lens sweeps and finds (cycle + payoff) ──────────────────────
123
+
124
+ const lens: Variants = {
125
+ rest: { rotate: 0, transition: returnTransition },
126
+ active: { rotate: 0, transition: returnTransition },
127
+ play: {
128
+ // Pivots on the handle end, so the glass sweeps an arc like someone scanning.
129
+ rotate: [0, 9, -16, 4, 0],
130
+ transition: beats([0, 0.16, 0.48, 0.78, 1], 0.6),
131
+ },
132
+ };
133
+
134
+ const found: Variants = {
135
+ rest: { scale: 0, opacity: 0, transition: { duration: 0.15 } },
136
+ active: { scale: 1, opacity: 1, transition: { duration: 0.15 } },
137
+ play: {
138
+ scale: [0, 0, 0, 1.3, 1],
139
+ opacity: [0, 0, 0, 1, 1],
140
+ transition: beats([0, 0.16, 0.48, 0.78, 1], 0.6),
141
+ },
142
+ };
143
+
144
+ export function MotionSearch(props: MotionIconProps) {
145
+ return (
146
+ <MotionIcon {...props}>
147
+ <motion.g variants={lens} style={{ originX: "21px", originY: "21px" }}>
148
+ <circle cx="11" cy="11" r="8" />
149
+ <path d="m21 21-4.34-4.34" />
150
+ {/* Something turns up in the glass on the return stroke. */}
151
+ <motion.circle
152
+ variants={found}
153
+ opacity={0}
154
+ style={{ originX: "11px", originY: "11px" }}
155
+ fill="currentColor"
156
+ cx="11"
157
+ cy="11"
158
+ r="2.5"
159
+ />
160
+ </motion.g>
161
+ </MotionIcon>
162
+ );
163
+ }
164
+
165
+ // ─── Share — the outer nodes depart (detach, no return) ───────────────────────
166
+
167
+ const SHARE_TIMES = [0, 0.2, 0.62, 1];
168
+
169
+ /** Each node launches along its own connecting line, then comes back. */
170
+ function shareNode(dx: number, dy: number): Variants {
171
+ return {
172
+ rest: { x: 0, y: 0, opacity: 1, transition: returnTransition },
173
+ active: { x: 0, y: 0, opacity: 1, transition: returnTransition },
174
+ play: {
175
+ x: [0, -dx * 0.18, dx, 0],
176
+ y: [0, -dy * 0.18, dy, 0],
177
+ opacity: [1, 1, 0.15, 1],
178
+ transition: beats(SHARE_TIMES, 0.58),
179
+ },
180
+ };
181
+ }
182
+
183
+ const shareTop = shareNode(7, -4);
184
+ const shareBottom = shareNode(7, 4);
185
+
186
+ export function MotionShare(props: MotionIconProps) {
187
+ return (
188
+ <MotionIcon {...props}>
189
+ <circle cx="6" cy="12" r="3" />
190
+ <motion.g variants={shareTop}>
191
+ <circle cx="18" cy="5" r="3" />
192
+ <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" />
193
+ </motion.g>
194
+ <motion.g variants={shareBottom}>
195
+ <circle cx="18" cy="19" r="3" />
196
+ <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" />
197
+ </motion.g>
198
+ </MotionIcon>
199
+ );
200
+ }
201
+
202
+ // ─── Sort — the two arrows trade ends (articulate) ────────────────────────────
203
+
204
+ const SORT_TIMES = [0, 0.18, 0.6, 1];
205
+
206
+ function sortArm(dy: number): Variants {
207
+ return {
208
+ rest: { y: 0, transition: returnTransition },
209
+ active: { y: 0, transition: returnTransition },
210
+ play: {
211
+ y: [0, -dy * 0.2, dy, 0],
212
+ transition: beats(SORT_TIMES, 0.56),
213
+ },
214
+ };
215
+ }
216
+
217
+ const sortDown = sortArm(6);
218
+ const sortUp = sortArm(-6);
219
+
220
+ export function MotionSort(props: MotionIconProps) {
221
+ return (
222
+ <MotionIcon {...props}>
223
+ <motion.g variants={sortDown}>
224
+ <path d="m21 16-4 4-4-4" />
225
+ <path d="M17 20V4" />
226
+ </motion.g>
227
+ <motion.g variants={sortUp}>
228
+ <path d="m3 8 4-4 4 4" />
229
+ <path d="M7 4v16" />
230
+ </motion.g>
231
+ </MotionIcon>
232
+ );
233
+ }
234
+
235
+ // ─── Check — confirm. Draws itself on ────────────────────────────────────────
236
+
237
+ /**
238
+ * `pathLength` is normalised by framer, so 0 → 1 gives the stroke-draw with no
239
+ * dash arithmetic. Kept short: a slow-drawing check feels laggy on a tap.
240
+ */
241
+ const checkStroke: Variants = {
242
+ rest: { pathLength: 0, opacity: 0, transition: { duration: 0.15 } },
243
+ active: { pathLength: 1, opacity: 1, transition: { duration: 0.15 } },
244
+ play: {
245
+ pathLength: [0, 1],
246
+ opacity: [1, 1],
247
+ transition: { duration: 0.26, ease: "easeOut" },
248
+ },
249
+ };
250
+
251
+ export function MotionCheck(props: MotionIconProps) {
252
+ return (
253
+ <MotionIcon {...props}>
254
+ {/* Static `opacity={0}`: `controls.set()` runs in an effect, i.e. after
255
+ first paint, so without a rest-matching SVG attribute every inactive
256
+ instance paints one frame of fully-drawn check. Very visible in a
257
+ selection list where most rows are inactive. Applies to every part
258
+ below whose rest pose is hidden. */}
259
+ <motion.path variants={checkStroke} opacity={0} d="M20 6 9 17l-5-5" />
260
+ </MotionIcon>
261
+ );
262
+ }
263
+
264
+ // ─── Theme — the sun's rays retract and the moon carves in (articulate) ──────
265
+
266
+ const SUN_RAYS = [
267
+ "M12 2v2",
268
+ "M12 20v2",
269
+ "m4.93 4.93 1.41 1.41",
270
+ "m17.66 17.66 1.41 1.41",
271
+ "M2 12h2",
272
+ "M20 12h2",
273
+ "m6.34 17.66-1.41 1.41",
274
+ "m19.07 4.93-1.41 1.41",
275
+ ];
276
+
277
+ const THEME_TIMES = [0, 0.3, 0.7, 1];
278
+
279
+ const rays: Variants = {
280
+ rest: { scale: 1, opacity: 1, transition: returnTransition },
281
+ active: { scale: 0, opacity: 0, transition: returnTransition },
282
+ play: {
283
+ scale: [1, 0.4, 0, 0],
284
+ opacity: [1, 0.5, 0, 0],
285
+ transition: beats(THEME_TIMES, 0.5),
286
+ },
287
+ };
288
+
289
+ const disc: Variants = {
290
+ rest: { scale: 1, opacity: 1, transition: returnTransition },
291
+ active: { scale: 0.6, opacity: 0, transition: returnTransition },
292
+ play: { scale: [1, 0.85, 0.6, 0.6], opacity: [1, 1, 0, 0], transition: beats(THEME_TIMES, 0.5) },
293
+ };
294
+
295
+ const crescent: Variants = {
296
+ rest: { scale: 0.6, opacity: 0, rotate: -35, transition: returnTransition },
297
+ active: { scale: 1, opacity: 1, rotate: 0, transition: returnTransition },
298
+ play: {
299
+ scale: [0.6, 0.7, 1.06, 1],
300
+ opacity: [0, 0, 1, 1],
301
+ rotate: [-35, -22, 4, 0],
302
+ transition: beats(THEME_TIMES, 0.5),
303
+ },
304
+ };
305
+
306
+ /**
307
+ * `active` is dark mode. The sun does not fade into the moon — its rays pull
308
+ * into the disc first, then the crescent swings in, so the two shapes never
309
+ * cross-dissolve into visual mush at 20px.
310
+ */
311
+ export function MotionTheme(props: MotionIconProps) {
312
+ const origin = { originX: "12px", originY: "12px" } as const;
313
+ return (
314
+ <MotionIcon {...props}>
315
+ <motion.g variants={rays} style={origin}>
316
+ {SUN_RAYS.map((d) => (
317
+ <path key={d} d={d} />
318
+ ))}
319
+ </motion.g>
320
+ <motion.circle variants={disc} style={origin} cx="12" cy="12" r="4" />
321
+ <motion.path
322
+ variants={crescent}
323
+ opacity={0}
324
+ style={origin}
325
+ d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
326
+ />
327
+ </MotionIcon>
328
+ );
329
+ }
330
+
331
+ // ─── Chevron — disclosure. Rotates, and overshoots on the way ────────────────
332
+
333
+ /**
334
+ * The one gesture already half-present in the app: seven sites rotate a
335
+ * disclosure chevron and four forgot to. The overshoot is what separates this
336
+ * from a plain CSS rotate — the arrow arrives, tips past, and settles.
337
+ */
338
+ const chevron: Variants = {
339
+ rest: { rotate: 0, y: 0, transition: returnTransition },
340
+ active: { rotate: 180, y: 0, transition: returnTransition },
341
+ play: {
342
+ rotate: [0, -12, 196, 180],
343
+ y: [0, 1, -1, 0],
344
+ transition: beats([0, 0.16, 0.72, 1], 0.44),
345
+ },
346
+ };
347
+
348
+ export function MotionChevron(props: MotionIconProps) {
349
+ return (
350
+ <MotionIcon {...props}>
351
+ <motion.path variants={chevron} style={{ originX: "12px", originY: "12px" }} d="m6 9 6 6 6-6" />
352
+ </MotionIcon>
353
+ );
354
+ }
355
+
356
+ // ─── Play / Pause — the bars converge into the triangle (articulate) ─────────
357
+
358
+ const PLAYPAUSE_TIMES = [0, 0.24, 0.72, 1];
359
+
360
+ /**
361
+ * `active` is playing, so `rest` shows the play triangle and `active` the two
362
+ * pause bars. The bars do not cross-fade with the triangle: they slide in from
363
+ * the triangle's own edges and straighten, so the shape reads as folding rather
364
+ * than as two icons dissolving.
365
+ */
366
+ const playHead: Variants = {
367
+ rest: { scale: 1, opacity: 1, transition: returnTransition },
368
+ active: { scale: 0.2, opacity: 0, transition: returnTransition },
369
+ play: { scale: [1, 1.1, 0.2, 0.2], opacity: [1, 1, 0, 0], transition: beats(PLAYPAUSE_TIMES, 0.46) },
370
+ };
371
+
372
+ function pauseBar(dx: number): Variants {
373
+ return {
374
+ rest: { x: dx, scaleY: 0.2, opacity: 0, transition: returnTransition },
375
+ active: { x: 0, scaleY: 1, opacity: 1, transition: returnTransition },
376
+ play: {
377
+ x: [dx, dx * 0.7, -dx * 0.08, 0],
378
+ scaleY: [0.2, 0.4, 1.08, 1],
379
+ opacity: [0, 0.4, 1, 1],
380
+ transition: beats(PLAYPAUSE_TIMES, 0.46),
381
+ },
382
+ };
383
+ }
384
+
385
+ const barLeft = pauseBar(5);
386
+ const barRight = pauseBar(-5);
387
+
388
+ export function MotionPlayPause(props: MotionIconProps) {
389
+ const origin = { originX: "12px", originY: "12px" } as const;
390
+ return (
391
+ <MotionIcon {...props}>
392
+ <motion.path
393
+ variants={playHead}
394
+ style={origin}
395
+ d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"
396
+ />
397
+ <motion.rect variants={barLeft} opacity={0} style={origin} x="5" y="3" width="5" height="18" rx="1" />
398
+ <motion.rect variants={barRight} opacity={0} style={origin} x="14" y="3" width="5" height="18" rx="1" />
399
+ </MotionIcon>
400
+ );
401
+ }
402
+
403
+ // ─── Eye — password reveal. The lid closes over it (articulate) ──────────────
404
+
405
+ const EYE_TIMES = [0, 0.3, 0.74, 1];
406
+
407
+ /**
408
+ * `active` is hidden, matching `EyeOff`. The eye does not swap glyph: the
409
+ * pupil shrinks as the lid narrows, and the slash draws across with
410
+ * `pathLength`, which is the one property that can grow a stroke without
411
+ * morphing its `d`.
412
+ */
413
+ const lid: Variants = {
414
+ rest: { scaleY: 1, transition: returnTransition },
415
+ active: { scaleY: 0.18, transition: returnTransition },
416
+ play: { scaleY: [1, 1.06, 0.14, 0.18], transition: beats(EYE_TIMES, 0.46) },
417
+ };
418
+
419
+ const pupil: Variants = {
420
+ rest: { scale: 1, opacity: 1, transition: returnTransition },
421
+ active: { scale: 0.2, opacity: 0, transition: returnTransition },
422
+ play: { scale: [1, 1, 0.2, 0.2], opacity: [1, 1, 0, 0], transition: beats(EYE_TIMES, 0.46) },
423
+ };
424
+
425
+ const slash: Variants = {
426
+ rest: { pathLength: 0, opacity: 0, transition: { duration: 0.16 } },
427
+ active: { pathLength: 1, opacity: 1, transition: { duration: 0.16 } },
428
+ play: {
429
+ pathLength: [0, 0, 1, 1],
430
+ opacity: [0, 1, 1, 1],
431
+ transition: beats(EYE_TIMES, 0.46),
432
+ },
433
+ };
434
+
435
+ export function MotionEye(props: MotionIconProps) {
436
+ const origin = { originX: "12px", originY: "12px" } as const;
437
+ return (
438
+ <MotionIcon {...props}>
439
+ <motion.path
440
+ variants={lid}
441
+ style={origin}
442
+ d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
443
+ />
444
+ <motion.circle variants={pupil} style={origin} cx="12" cy="12" r="3" />
445
+ <motion.path variants={slash} opacity={0} d="m2 2 20 20" />
446
+ </MotionIcon>
447
+ );
448
+ }
449
+
450
+ // ─── Star — rating filter. The points spring out (articulate) ───────────────
451
+
452
+ const STAR_TIMES = [0, 0.2, 0.62, 1];
453
+
454
+ /**
455
+ * The star grows its points rather than fading a fill in: it pulls in tight
456
+ * first, then springs out past full size, filling as it goes.
457
+ */
458
+ const starBody: Variants = {
459
+ rest: { scale: 1, rotate: 0, fillOpacity: 0, transition: returnTransition },
460
+ active: { scale: 1, rotate: 0, fillOpacity: 1, transition: returnTransition },
461
+ play: {
462
+ scale: [1, 0.72, 1.22, 1],
463
+ rotate: [0, -14, 6, 0],
464
+ fillOpacity: [0, 0, 1, 1],
465
+ transition: beats(STAR_TIMES, 0.5),
466
+ },
467
+ };
468
+
469
+ export function MotionStar(props: MotionIconProps) {
470
+ return (
471
+ <MotionIcon {...props}>
472
+ <motion.path
473
+ variants={starBody}
474
+ style={{ originX: "12px", originY: "12px" }}
475
+ fill="currentColor"
476
+ fillOpacity={0}
477
+ d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"
478
+ />
479
+ </MotionIcon>
480
+ );
481
+ }
482
+
483
+ // ─── Copy — the sheet slides out, then the check lands (cycle + draw) ───────
484
+
485
+ const COPY_TIMES = [0, 0.22, 0.5, 0.76, 1];
486
+
487
+ /** The front sheet peels off the back one and returns. */
488
+ const copySheet: Variants = {
489
+ rest: { x: 0, y: 0, opacity: 1, transition: returnTransition },
490
+ active: { x: 0, y: 0, opacity: 0, transition: returnTransition },
491
+ play: {
492
+ x: [0, 2.5, 4, 0, 0],
493
+ y: [0, -2.5, -4, 0, 0],
494
+ opacity: [1, 1, 1, 0, 0],
495
+ transition: beats(COPY_TIMES, 0.6),
496
+ },
497
+ };
498
+
499
+ const copyBack: Variants = {
500
+ rest: { opacity: 1, transition: returnTransition },
501
+ active: { opacity: 0, transition: returnTransition },
502
+ play: { opacity: [1, 1, 1, 0, 0], transition: beats(COPY_TIMES, 0.6) },
503
+ };
504
+
505
+ /** The confirmation draws on only once the sheet has come back. */
506
+ const copyCheck: Variants = {
507
+ rest: { pathLength: 0, opacity: 0, transition: { duration: 0.14 } },
508
+ active: { pathLength: 1, opacity: 1, transition: { duration: 0.14 } },
509
+ play: {
510
+ pathLength: [0, 0, 0, 0, 1],
511
+ opacity: [0, 0, 0, 1, 1],
512
+ transition: beats(COPY_TIMES, 0.6),
513
+ },
514
+ };
515
+
516
+ export function MotionCopy(props: MotionIconProps) {
517
+ return (
518
+ <MotionIcon {...props}>
519
+ <motion.path variants={copyBack} d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
520
+ <motion.rect variants={copySheet} width="14" height="14" x="8" y="8" rx="2" ry="2" />
521
+ <motion.path variants={copyCheck} opacity={0} d="M20 6 9 17l-5-5" />
522
+ </MotionIcon>
523
+ );
524
+ }
525
+
526
+ // ─── Refresh — one full turn, overshooting and settling (cycle) ─────────────
527
+
528
+ /**
529
+ * The object does its one job. A full 360 rather than a partial sweep, so the
530
+ * arrowheads end exactly where they started and the rest pose is untouched.
531
+ */
532
+ const refreshRing: Variants = {
533
+ rest: { rotate: 0, transition: returnTransition },
534
+ active: { rotate: 0, transition: returnTransition },
535
+ play: {
536
+ rotate: [0, -18, 372, 360],
537
+ transition: beats([0, 0.14, 0.8, 1], 0.66),
538
+ },
539
+ };
540
+
541
+ export function MotionRefresh(props: MotionIconProps) {
542
+ return (
543
+ <MotionIcon {...props}>
544
+ <motion.g variants={refreshRing} style={{ originX: "12px", originY: "12px" }}>
545
+ <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
546
+ <path d="M21 3v5h-5" />
547
+ <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
548
+ <path d="M8 16H3v5" />
549
+ </motion.g>
550
+ </MotionIcon>
551
+ );
552
+ }