@bearmenu/ui 0.8.12 → 0.8.14

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 (299) hide show
  1. package/dist/{chunk-66MAPLXS.js → chunk-2ADCZXZJ.js} +1 -1
  2. package/dist/{chunk-2DEIXTBN.js → chunk-2ZXIXQIU.js} +1 -1
  3. package/dist/{chunk-YOKHBAIH.js → chunk-4OF4T4AI.js} +12 -11
  4. package/dist/{chunk-MZL7CGCL.js → chunk-4XDJXMLF.js} +2 -2
  5. package/dist/{chunk-YYGB4G4Q.js → chunk-CZK2MRS2.js} +1 -1
  6. package/dist/chunk-DJD3EMPK.js +126 -0
  7. package/dist/chunk-EZG2NELX.js +5 -0
  8. package/dist/{chunk-VAXOQS7W.js → chunk-F76NCVVR.js} +1 -1
  9. package/dist/{chunk-VG3PND3D.js → chunk-FMTJGO76.js} +1 -1
  10. package/dist/{chunk-UXWAOISG.js → chunk-FVSKGCK3.js} +2 -2
  11. package/dist/{chunk-2XCZJWTV.js → chunk-GNCWJWFE.js} +1 -1
  12. package/dist/{chunk-X3WMRKOW.js → chunk-HDR7V6IZ.js} +1 -1
  13. package/dist/{chunk-HSIYYA2E.js → chunk-HQU6QVAN.js} +5 -5
  14. package/dist/chunk-I5V446BX.js +27 -0
  15. package/dist/{chunk-WCTHZKU7.js → chunk-ICOU5QHT.js} +1 -1
  16. package/dist/{chunk-G7IFL4Z4.js → chunk-L3VEMP5A.js} +57 -9
  17. package/dist/chunk-LQWGZIYA.js +5 -0
  18. package/dist/{chunk-XNA2INVQ.js → chunk-MCOHLWRS.js} +2 -2
  19. package/dist/{chunk-3GWWZKX7.js → chunk-MMUBH76A.js} +21 -1
  20. package/dist/{chunk-ZDAHKI3A.js → chunk-MWB7S54O.js} +1 -1
  21. package/dist/{chunk-D5QHEHXJ.js → chunk-NMEVJT5Q.js} +1 -1
  22. package/dist/{chunk-3TNPSN4D.js → chunk-NSNEANOQ.js} +2 -2
  23. package/dist/{chunk-WSRWFA6K.js → chunk-NWZGSLPU.js} +1 -1
  24. package/dist/{chunk-LEAYBICI.js → chunk-OIV7B44M.js} +1 -1
  25. package/dist/{chunk-2CXU5WZO.js → chunk-P43B3ONY.js} +1 -1
  26. package/dist/{chunk-FRPPIXMY.js → chunk-PDTPO6NG.js} +1 -1
  27. package/dist/{chunk-6XMYEZVR.js → chunk-PX6RICS5.js} +1 -1
  28. package/dist/{chunk-6TEEY5YA.js → chunk-S33K4RC5.js} +1 -1
  29. package/dist/{chunk-3VMDCCCX.js → chunk-SC2VUTUI.js} +1 -1
  30. package/dist/{chunk-24RFI4RQ.js → chunk-SXQZ7DLH.js} +4 -4
  31. package/dist/{chunk-UDTG3RSE.js → chunk-T6S2ZBV6.js} +2 -2
  32. package/dist/{chunk-DTXNDLSE.js → chunk-WYTJR2YU.js} +16 -1
  33. package/dist/{chunk-QXHMPJ35.js → chunk-XBBEX4SF.js} +1 -1
  34. package/dist/chunk-XCB3F6SF.js +16 -0
  35. package/dist/{chunk-7BWTYWM3.js → chunk-ZLMRVBD7.js} +1 -1
  36. package/dist/{chunk-7A42J73Q.js → chunk-ZLPWEHZO.js} +1 -1
  37. package/dist/components/accordion.js +1 -1
  38. package/dist/components/alert-dialog.js +2 -2
  39. package/dist/components/alert.d.ts +2 -2
  40. package/dist/components/alert.js +2 -2
  41. package/dist/components/aspect-ratio.js +2 -2
  42. package/dist/components/avatar.js +1 -1
  43. package/dist/components/badge.d.ts +1 -1
  44. package/dist/components/badge.js +2 -2
  45. package/dist/components/breadcrumb.js +1 -1
  46. package/dist/components/button-group.js +1 -1
  47. package/dist/components/button.d.ts +1 -1
  48. package/dist/components/button.js +2 -2
  49. package/dist/components/calendar.js +2 -2
  50. package/dist/components/card.d.ts +2 -2
  51. package/dist/components/card.js +3 -3
  52. package/dist/components/carousel.js +2 -2
  53. package/dist/components/category-deck.js +2 -2
  54. package/dist/components/checkbox.js +2 -2
  55. package/dist/components/circular-progress.js +1 -1
  56. package/dist/components/collapsible.js +1 -1
  57. package/dist/components/combobox.js +5 -5
  58. package/dist/components/command.d.ts +15 -15
  59. package/dist/components/command.js +3 -3
  60. package/dist/components/concept-card.js +2 -2
  61. package/dist/components/concept-compare-rail.js +3 -3
  62. package/dist/components/concept-compare.d.ts +2 -1
  63. package/dist/components/concept-compare.js +5 -5
  64. package/dist/components/concept-convergence.js +4 -4
  65. package/dist/components/convergence.js +3 -3
  66. package/dist/components/coverflow.js +1 -1
  67. package/dist/components/currency-input.js +2 -2
  68. package/dist/components/date-input.d.ts +1 -1
  69. package/dist/components/date-input.js +2 -2
  70. package/dist/components/day-rail-chip.js +2 -2
  71. package/dist/components/description-list.js +1 -1
  72. package/dist/components/dialog.js +2 -2
  73. package/dist/components/doorway-tiles.d.ts +57 -0
  74. package/dist/components/doorway-tiles.js +86 -0
  75. package/dist/components/drawer.js +1 -1
  76. package/dist/components/drift-rail.js +1 -1
  77. package/dist/components/dropdown-menu.js +1 -1
  78. package/dist/components/empty-state.js +3 -3
  79. package/dist/components/event-card.js +4 -4
  80. package/dist/components/event-compact-row.js +4 -4
  81. package/dist/components/event-day-header.js +2 -2
  82. package/dist/components/event-day-separator.js +3 -3
  83. package/dist/components/event-detail-panel.js +4 -4
  84. package/dist/components/event-hub-hero.js +2 -2
  85. package/dist/components/event-index-row.js +4 -4
  86. package/dist/components/event-list-row-wide.js +5 -5
  87. package/dist/components/event-list-row.js +5 -5
  88. package/dist/components/event-poster-fallback.js +1 -1
  89. package/dist/components/event-types.js +1 -1
  90. package/dist/components/events-empty-state.js +1 -1
  91. package/dist/components/explainer-steps.js +1 -1
  92. package/dist/components/facet-group.js +2 -2
  93. package/dist/components/featured-rail-item.js +2 -2
  94. package/dist/components/field.js +2 -2
  95. package/dist/components/filter-category-row.js +3 -3
  96. package/dist/components/form.js +2 -2
  97. package/dist/components/glass-ground.js +2 -2
  98. package/dist/components/grade-atom.d.ts +3 -0
  99. package/dist/components/grade-atom.js +4 -0
  100. package/dist/components/haptic-button.js +2 -2
  101. package/dist/components/hour-band.js +4 -4
  102. package/dist/components/hour-group.js +4 -4
  103. package/dist/components/hub-surface.js +1 -1
  104. package/dist/components/hue-chip.js +1 -1
  105. package/dist/components/icon.js +1 -1
  106. package/dist/components/input-group.js +1 -1
  107. package/dist/components/input-otp.js +1 -1
  108. package/dist/components/input.js +2 -2
  109. package/dist/components/item.d.ts +2 -2
  110. package/dist/components/item.js +1 -1
  111. package/dist/components/kbd.js +1 -1
  112. package/dist/components/kitchen-dossier.d.ts +2 -1
  113. package/dist/components/kitchen-dossier.js +3 -3
  114. package/dist/components/label.js +2 -2
  115. package/dist/components/lazy-markdown.js +1 -1
  116. package/dist/components/link-button.js +2 -2
  117. package/dist/components/markdown-renderer.js +1 -1
  118. package/dist/components/menu-dead-end.d.ts +2 -1
  119. package/dist/components/menu-dead-end.js +1 -1
  120. package/dist/components/menu-family-block.d.ts +2 -1
  121. package/dist/components/menu-family-block.js +14 -8
  122. package/dist/components/menu-family-card.d.ts +2 -1
  123. package/dist/components/menu-family-card.js +1 -1
  124. package/dist/components/menu-field.js +1 -1
  125. package/dist/components/menu-filter-chips.d.ts +2 -1
  126. package/dist/components/menu-filter-chips.js +3 -3
  127. package/dist/components/menu-group-rows.d.ts +2 -1
  128. package/dist/components/menu-group-rows.js +2 -2
  129. package/dist/components/menu-hub-hero.js +1 -1
  130. package/dist/components/menu-index-header.d.ts +2 -1
  131. package/dist/components/menu-index-header.js +2 -2
  132. package/dist/components/menu-index-list.d.ts +9 -8
  133. package/dist/components/menu-index-list.js +4 -4
  134. package/dist/components/menu-intent-hero.d.ts +3 -2
  135. package/dist/components/menu-intent-hero.js +1 -1
  136. package/dist/components/menu-item.d.ts +2 -1
  137. package/dist/components/menu-item.js +4 -4
  138. package/dist/components/menu-kitchen-list.d.ts +2 -1
  139. package/dist/components/menu-kitchen-list.js +4 -4
  140. package/dist/components/menu-mocks.d.ts +16 -5
  141. package/dist/components/menu-mocks.js +23 -2
  142. package/dist/components/menu-types.d.ts +21 -34
  143. package/dist/components/motion-icon-actions.d.ts +27 -0
  144. package/dist/components/motion-icon-actions.js +336 -0
  145. package/dist/components/motion-icon-controls.js +3 -3
  146. package/dist/components/motion-icon-set.d.ts +13 -1
  147. package/dist/components/motion-icon-set.js +197 -6
  148. package/dist/components/motion-icon.d.ts +15 -1
  149. package/dist/components/motion-icon.js +2 -2
  150. package/dist/components/multi-select-combobox.js +7 -7
  151. package/dist/components/multi-select-menu.js +2 -2
  152. package/dist/components/native-select.js +1 -1
  153. package/dist/components/open-now-list.d.ts +2 -1
  154. package/dist/components/open-now-list.js +2 -2
  155. package/dist/components/pagination.js +2 -2
  156. package/dist/components/phone-frame.js +1 -1
  157. package/dist/components/photo-ground.js +1 -1
  158. package/dist/components/place-about-tab.d.ts +2 -1
  159. package/dist/components/place-about-tab.js +6 -6
  160. package/dist/components/place-card.d.ts +94 -12
  161. package/dist/components/place-card.js +203 -128
  162. package/dist/components/place-details-mobile.d.ts +2 -1
  163. package/dist/components/place-details-mobile.js +7 -7
  164. package/dist/components/place-schedule-week.d.ts +2 -1
  165. package/dist/components/place-schedule-week.js +3 -3
  166. package/dist/components/place-types.d.ts +3 -265
  167. package/dist/components/popover.js +2 -2
  168. package/dist/components/poster-stack.js +1 -1
  169. package/dist/components/poster-tape.js +1 -1
  170. package/dist/components/poster-wall.d.ts +22 -6
  171. package/dist/components/poster-wall.js +197 -12
  172. package/dist/components/progress.js +1 -1
  173. package/dist/components/qa-block.d.ts +2 -1
  174. package/dist/components/qa-block.js +1 -1
  175. package/dist/components/quiet-stretch.js +1 -1
  176. package/dist/components/radio-group.js +1 -1
  177. package/dist/components/rating.js +1 -1
  178. package/dist/components/scroll-area.js +1 -1
  179. package/dist/components/scroll-fade.js +2 -2
  180. package/dist/components/search-bar.js +1 -1
  181. package/dist/components/searchable-select.js +5 -5
  182. package/dist/components/section-header.js +2 -2
  183. package/dist/components/segmented-nav.d.ts +24 -4
  184. package/dist/components/segmented-nav.js +11 -2
  185. package/dist/components/select.js +1 -1
  186. package/dist/components/selection-bar.js +1 -1
  187. package/dist/components/separator.js +2 -2
  188. package/dist/components/sheet.js +1 -1
  189. package/dist/components/signature-chip.js +3 -3
  190. package/dist/components/skeleton-card.js +3 -3
  191. package/dist/components/skeleton.js +2 -2
  192. package/dist/components/slider.js +1 -1
  193. package/dist/components/sliding-panels.js +2 -2
  194. package/dist/components/snap-rail.d.ts +0 -5
  195. package/dist/components/snap-rail.js +2 -2
  196. package/dist/components/sonner.js +1 -1
  197. package/dist/components/sort-option-list.js +1 -1
  198. package/dist/components/spinner.d.ts +2 -2
  199. package/dist/components/spinner.js +1 -1
  200. package/dist/components/stat-banner.js +1 -1
  201. package/dist/components/stepper.js +1 -1
  202. package/dist/components/sticky-container.js +1 -1
  203. package/dist/components/story.js +1 -1
  204. package/dist/components/switch.js +1 -1
  205. package/dist/components/table.js +1 -1
  206. package/dist/components/tabs.js +2 -2
  207. package/dist/components/tag-group.js +2 -2
  208. package/dist/components/tag.d.ts +2 -2
  209. package/dist/components/tag.js +2 -2
  210. package/dist/components/text.d.ts +3 -3
  211. package/dist/components/text.js +2 -2
  212. package/dist/components/textarea.js +1 -1
  213. package/dist/components/timeline-band.js +4 -4
  214. package/dist/components/timeline-playhead.js +4 -4
  215. package/dist/components/toggle-group.js +2 -2
  216. package/dist/components/toggle.js +2 -2
  217. package/dist/components/tooltip.js +1 -1
  218. package/dist/components/top-comparison.d.ts +2 -1
  219. package/dist/components/top-comparison.js +1 -1
  220. package/dist/components/top-cross-link.d.ts +2 -1
  221. package/dist/components/top-cross-link.js +1 -1
  222. package/dist/components/top-ranking-entry.d.ts +2 -1
  223. package/dist/components/top-ranking-entry.js +6 -6
  224. package/dist/components/top-ranking-hero.d.ts +2 -1
  225. package/dist/components/top-ranking-hero.js +1 -1
  226. package/dist/components/top-ranking-legend.js +1 -1
  227. package/dist/components/top-types.d.ts +3 -1
  228. package/dist/components/use-mobile.js +1 -1
  229. package/dist/components/venue-events-block.js +5 -5
  230. package/dist/components/venue-grade-dimensions.d.ts +2 -1
  231. package/dist/components/venue-grade-dimensions.js +1 -1
  232. package/dist/components/venue-quotes.d.ts +2 -1
  233. package/dist/components/venue-quotes.js +1 -1
  234. package/dist/components/venue-wall.d.ts +2 -1
  235. package/dist/components/venue-wall.js +1 -1
  236. package/dist/components/weekday-theme-banner.js +1 -1
  237. package/dist/components/weekend-panel.js +4 -4
  238. package/dist/hooks/use-drag-swipe.js +1 -1
  239. package/dist/hooks/use-haptic.js +1 -1
  240. package/dist/hooks/use-media-query.js +1 -1
  241. package/dist/hooks/use-mobile.js +1 -1
  242. package/dist/hooks/use-mounted-theme.js +1 -1
  243. package/dist/hooks/use-sticky.js +1 -1
  244. package/dist/lib/card-hover.js +2 -18
  245. package/dist/lib/grade.d.ts +54 -0
  246. package/dist/lib/grade.js +2 -0
  247. package/dist/lib/icon-depth.d.ts +35 -0
  248. package/dist/lib/icon-depth.js +2 -0
  249. package/dist/lib/icon-registry.js +1 -1
  250. package/dist/lib/motion.js +2 -2
  251. package/dist/lib/overlay-pill.d.ts +30 -0
  252. package/dist/lib/overlay-pill.js +2 -0
  253. package/dist/lib/utils.js +1 -1
  254. package/dist/place-types-BHdeMMiG.d.ts +317 -0
  255. package/package.json +1 -1
  256. package/src/components/colour-dictionary.stories.tsx +268 -82
  257. package/src/components/doorway-tiles.stories.tsx +83 -0
  258. package/src/components/doorway-tiles.test.tsx +51 -0
  259. package/src/components/doorway-tiles.tsx +225 -0
  260. package/src/components/grade-atom.stories.tsx +81 -0
  261. package/src/components/grade-atom.tsx +256 -0
  262. package/src/components/hue-chip.stories.tsx +2 -2
  263. package/src/components/menu-family-block.stories.tsx +29 -0
  264. package/src/components/menu-family-block.test.tsx +51 -0
  265. package/src/components/menu-family-block.tsx +20 -4
  266. package/src/components/menu-index-list.tsx +7 -7
  267. package/src/components/menu-intent-hero.tsx +1 -1
  268. package/src/components/menu-kitchen-list.tsx +4 -1
  269. package/src/components/menu-mocks.ts +39 -3
  270. package/src/components/menu-types.ts +17 -33
  271. package/src/components/motion-icon-actions.test.tsx +115 -0
  272. package/src/components/motion-icon-actions.tsx +580 -0
  273. package/src/components/motion-icon-controls.test.tsx +1 -0
  274. package/src/components/motion-icon-controls.tsx +18 -13
  275. package/src/components/motion-icon-set.stories.tsx +62 -3
  276. package/src/components/motion-icon-set.tsx +349 -2
  277. package/src/components/motion-icon.test.tsx +180 -1
  278. package/src/components/motion-icon.tsx +97 -8
  279. package/src/components/place-card.stories.tsx +66 -76
  280. package/src/components/place-card.test.tsx +188 -100
  281. package/src/components/place-card.tsx +356 -148
  282. package/src/components/place-types.ts +17 -4
  283. package/src/components/poster-wall.stories.tsx +84 -0
  284. package/src/components/poster-wall.test.tsx +94 -3
  285. package/src/components/poster-wall.tsx +257 -9
  286. package/src/components/segmented-nav.stories.tsx +72 -1
  287. package/src/components/segmented-nav.test.tsx +96 -0
  288. package/src/components/segmented-nav.tsx +36 -4
  289. package/src/components/snap-rail.tsx +19 -0
  290. package/src/globals.css +26 -0
  291. package/src/lib/grade.test.ts +82 -0
  292. package/src/lib/grade.ts +76 -0
  293. package/src/lib/icon-depth.ts +35 -0
  294. package/src/lib/overlay-pill.ts +29 -0
  295. package/dist/components/menu-family-tiles.d.ts +0 -19
  296. package/dist/components/menu-family-tiles.js +0 -57
  297. package/src/components/menu-family-tiles.stories.tsx +0 -54
  298. package/src/components/menu-family-tiles.test.tsx +0 -30
  299. package/src/components/menu-family-tiles.tsx +0 -132
@@ -1,7 +1,35 @@
1
1
  import * as React from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react";
3
3
 
4
- import { MotionHouse, MotionUtensils, MotionMapPin, MotionCalendar } from "./motion-icon-set";
4
+ import {
5
+ MotionHouse,
6
+ MotionUtensils,
7
+ MotionMapPin,
8
+ MotionCalendar,
9
+ MotionLayoutGrid,
10
+ MotionWine,
11
+ MotionMap,
12
+ MotionFlame,
13
+ MotionCalendarClock,
14
+ MotionList,
15
+ } from "./motion-icon-set";
16
+ import {
17
+ MotionArrow,
18
+ MotionCaret,
19
+ MotionX,
20
+ MotionCircleX,
21
+ MotionExternalLink,
22
+ MotionSend,
23
+ MotionNavigation,
24
+ MotionLocate,
25
+ MotionMaximize,
26
+ MotionPhone,
27
+ MotionMail,
28
+ MotionFlag,
29
+ MotionLogIn,
30
+ MotionLogOut,
31
+ MotionSettings,
32
+ } from "./motion-icon-actions";
5
33
  import {
6
34
  MotionHeart,
7
35
  MotionBookmark,
@@ -36,6 +64,35 @@ const OBJECTS: Glyph[] = [
36
64
  { name: "Utensils", Icon: MotionUtensils, gesture: "uncrosses into a place setting · holds" },
37
65
  { name: "MapPin", Icon: MotionMapPin, gesture: "jumps, fills on the descent · holds" },
38
66
  { name: "Calendar", Icon: MotionCalendar, gesture: "rolls four pages" },
67
+ { name: "LayoutGrid", Icon: MotionLayoutGrid, gesture: "tiles shuffle one slot round the board" },
68
+ { name: "Wine", Icon: MotionWine, gesture: "pours, sloshes past the line · holds" },
69
+ { name: "Map", Icon: MotionMap, gesture: "folds shut, snaps open" },
70
+ { name: "Flame", Icon: MotionFlame, gesture: "gathers, flares, fills at the peak" },
71
+ { name: "CalendarClock", Icon: MotionCalendarClock, gesture: "hand whips a turn, a wedge sweeps · holds" },
72
+ { name: "List", Icon: MotionList, gesture: "bottom row lifts to the top" },
73
+ ];
74
+
75
+ const ArrowLeft = (p: MotionIconProps) => <MotionArrow direction="left" {...p} />;
76
+ const CaretLeft = (p: MotionIconProps) => <MotionCaret direction="left" {...p} />;
77
+
78
+ const ACTIONS: Glyph[] = [
79
+ { name: "Arrow", Icon: MotionArrow, gesture: "head fires ahead, shaft chases" },
80
+ { name: "Arrow ←", Icon: ArrowLeft, gesture: "the mirror, for back" },
81
+ { name: "Caret", Icon: MotionCaret, gesture: "coils, darts folded, springs open" },
82
+ { name: "Caret ←", Icon: CaretLeft, gesture: "the mirror, for carousel prev" },
83
+ { name: "X", Icon: MotionX, gesture: "blades shut to a bar, reopen" },
84
+ { name: "CircleX", Icon: MotionCircleX, gesture: "pressed, ejects its X, reloads" },
85
+ { name: "ExternalLink", Icon: MotionExternalLink, gesture: "arrow leaves, the next one loads" },
86
+ { name: "Send", Icon: MotionSend, gesture: "arrow fires out, stop block drops in · holds" },
87
+ { name: "Navigation", Icon: MotionNavigation, gesture: "needle hunts for its bearing" },
88
+ { name: "Locate", Icon: MotionLocate, gesture: "reticle skids off, locks on the dot" },
89
+ { name: "Maximize", Icon: MotionMaximize, gesture: "corners shove apart, snap back" },
90
+ { name: "Phone", Icon: MotionPhone, gesture: "lifted to the ear, dropped on the hook" },
91
+ { name: "Mail", Icon: MotionMail, gesture: "flap flips open, slaps shut" },
92
+ { name: "Flag", Icon: MotionFlag, gesture: "raised, planted, cloth whips" },
93
+ { name: "LogIn", Icon: MotionLogIn, gesture: "arrow goes through the door" },
94
+ { name: "LogOut", Icon: MotionLogOut, gesture: "arrow leaves, the next comes out" },
95
+ { name: "Settings", Icon: MotionSettings, gesture: "gear turns two teeth, hub seats" },
39
96
  ];
40
97
 
41
98
  const CONTROLS: Glyph[] = [
@@ -49,13 +106,13 @@ const CONTROLS: Glyph[] = [
49
106
  { name: "Theme", Icon: MotionTheme, gesture: "rays retract, crescent swings in · holds" },
50
107
  { name: "Chevron", Icon: MotionChevron, gesture: "turns over, tips past, settles · holds" },
51
108
  { name: "PlayPause", Icon: MotionPlayPause, gesture: "triangle folds into two bars · holds" },
52
- { name: "Eye", Icon: MotionEye, gesture: "lid narrows, slash draws across · holds" },
109
+ { name: "Eye", Icon: MotionEye, gesture: "blinks, reopens onto the slash · holds" },
53
110
  { name: "Star", Icon: MotionStar, gesture: "pulls in, springs its points out · holds" },
54
111
  { name: "Copy", Icon: MotionCopy, gesture: "sheet peels off, check lands · holds" },
55
112
  { name: "Refresh", Icon: MotionRefresh, gesture: "one full turn, overshoots, settles" },
56
113
  ];
57
114
 
58
- const ALL = [...OBJECTS, ...CONTROLS];
115
+ const ALL = [...OBJECTS, ...ACTIONS, ...CONTROLS];
59
116
 
60
117
  const meta = {
61
118
  title: "Motion/Animated icons",
@@ -160,6 +217,8 @@ export const Family: Story = {
160
217
 
161
218
  /** The objects and the controls separately, when tuning one group at a time. */
162
219
  export const Objects: Story = { render: () => <Gallery glyphs={OBJECTS} size={28} /> };
220
+ /** The action vocabulary (`lib/icon-registry`) — most of these render at 16px in a `Button`, so check them at that size too. */
221
+ export const Actions: Story = { render: () => <Gallery glyphs={ACTIONS} size={28} /> };
163
222
  export const Controls: Story = { render: () => <Gallery glyphs={CONTROLS} size={28} /> };
164
223
 
165
224
  /**
@@ -20,6 +20,7 @@ import {
20
20
  beats,
21
21
  pivot,
22
22
  returnTransition,
23
+ useSvgId,
23
24
  type MotionIconProps,
24
25
  } from "./motion-icon";
25
26
 
@@ -221,8 +222,7 @@ const reel: Variants = {
221
222
  * still, which is what sells the contents moving.
222
223
  */
223
224
  export function MotionCalendar(props: MotionIconProps) {
224
- // React's useId contains colons, which are not safe in an SVG id / url(#…).
225
- const clipId = `cal-body-${React.useId().replace(/:/g, "")}`;
225
+ const clipId = useSvgId("cal-body");
226
226
 
227
227
  return (
228
228
  <MotionIcon {...props}>
@@ -245,3 +245,350 @@ export function MotionCalendar(props: MotionIconProps) {
245
245
  </MotionIcon>
246
246
  );
247
247
  }
248
+
249
+ // ─── Layout grid — the tiles shuffle one slot around the board (cycle content) ─
250
+
251
+ /**
252
+ * Lucide's `layout-grid`: four 7-unit tiles on an 11-unit pitch. Each tile
253
+ * slides along the outer edge to the next corner clockwise, in a chase — the
254
+ * top-left tile leads and each following one starts a beat behind. Mid-flight
255
+ * the four sit on the edge midpoints, so the outline goes square → plus →
256
+ * square; that is the silhouette change. Nothing rotates: they slide, which is
257
+ * what makes it a shuffle rather than "turn the icon 90°".
258
+ *
259
+ * Travel is the full 11-unit pitch (46% of the box), above the 25–40% band.
260
+ * That is the geometry's own step — a half-slot shuffle ends on the edge
261
+ * midpoints, which is not a rest pose — and the band exists to guarantee
262
+ * visibility, not to cap it. Nothing leaves the box: the overshoot puts a
263
+ * tile's far edge at 22.5 + stroke.
264
+ *
265
+ * `play` ends with every tile one slot from its `rest` values although the
266
+ * picture is identical; `useIconGesture` snaps the values back once the
267
+ * gesture resolves, so leaving never slides them counter-clockwise.
268
+ */
269
+ const GRID_TIMES = [0, 0.14, 0.6, 0.8, 1];
270
+ const GRID_PITCH = 11;
271
+ /** Per-tile delay in the chase, seconds. */
272
+ const GRID_CHASE = 0.03;
273
+
274
+ /** Anticipation against the travel, the edge, a 1.5 overshoot, a 0.4 rebound, the corner. */
275
+ const slide = (direction: 1 | -1) =>
276
+ [0, -1.5, GRID_PITCH + 1.5, GRID_PITCH - 0.4, GRID_PITCH].map((v) => v * direction);
277
+
278
+ const chase = (order: number) => ({ ...beats(GRID_TIMES, 0.58), delay: order * GRID_CHASE });
279
+
280
+ function tileAlongX(direction: 1 | -1, order: number): Variants {
281
+ return {
282
+ rest: { x: 0, transition: returnTransition },
283
+ active: { x: 0, transition: returnTransition },
284
+ play: { x: slide(direction), transition: chase(order) },
285
+ };
286
+ }
287
+
288
+ function tileAlongY(direction: 1 | -1, order: number): Variants {
289
+ return {
290
+ rest: { y: 0, transition: returnTransition },
291
+ active: { y: 0, transition: returnTransition },
292
+ play: { y: slide(direction), transition: chase(order) },
293
+ };
294
+ }
295
+
296
+ // Clockwise from the top-left: TL → TR (x+), TR → BR (y+), BR → BL (x−), BL → TL (y−).
297
+ const tileTopLeft = tileAlongX(1, 0);
298
+ const tileTopRight = tileAlongY(1, 1);
299
+ const tileBottomRight = tileAlongX(-1, 2);
300
+ const tileBottomLeft = tileAlongY(-1, 3);
301
+
302
+ export function MotionLayoutGrid(props: MotionIconProps) {
303
+ return (
304
+ <MotionIcon {...props}>
305
+ <motion.rect variants={tileTopLeft} width="7" height="7" x="3" y="3" rx="1" />
306
+ <motion.rect variants={tileTopRight} width="7" height="7" x="14" y="3" rx="1" />
307
+ <motion.rect variants={tileBottomRight} width="7" height="7" x="14" y="14" rx="1" />
308
+ <motion.rect variants={tileBottomLeft} width="7" height="7" x="3" y="14" rx="1" />
309
+ </MotionIcon>
310
+ );
311
+ }
312
+
313
+ // ─── Wine — pours, sloshes past the line, holds full (fall and land) ──────────
314
+
315
+ const WINE_TIMES = [0, 0.16, 0.6, 0.78, 1];
316
+
317
+ /**
318
+ * The bowl's bottom (empty), the line lucide already draws at the widest
319
+ * chord (`M7 10h10`, the "pour to here" mark), and the slosh two units above it.
320
+ */
321
+ const WINE_EMPTY = 15;
322
+ const WINE_LINE = 10;
323
+ const WINE_SLOSH = 8;
324
+
325
+ /**
326
+ * The liquid is a filled rect under a clip of the bowl, so its top edge IS the
327
+ * level. `attrY` moves the attribute rather than a transform so the rect's
328
+ * geometry itself changes under the `scaleY` the whole glass carries — the
329
+ * level then scales with the glass instead of fighting it. And the level has
330
+ * to rise, not fade: a rising solid is what sells a pour, which is why this
331
+ * does not reuse the pin's `fillOpacity` trick.
332
+ */
333
+ const liquid: Variants = {
334
+ rest: { attrY: WINE_EMPTY, transition: returnTransition },
335
+ active: { attrY: WINE_LINE, transition: returnTransition },
336
+ play: {
337
+ // Sits empty through the squat, rises past the line at the apex, drops
338
+ // back, settles on the line.
339
+ attrY: [WINE_EMPTY, WINE_EMPTY, WINE_SLOSH, WINE_LINE + 0.5, WINE_LINE],
340
+ transition: beats(WINE_TIMES),
341
+ },
342
+ };
343
+
344
+ /** The glass squats on its foot as the pour lands, stretches with the rise, settles. */
345
+ const glass: Variants = {
346
+ rest: { scaleY: 1, transition: returnTransition },
347
+ active: { scaleY: 1, transition: returnTransition },
348
+ play: { scaleY: [1, 0.9, 1.03, 0.98, 1], transition: beats(WINE_TIMES) },
349
+ };
350
+
351
+ const BOWL = "M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z";
352
+
353
+ /**
354
+ * The one gesture that HOLDS on the drinks side: rest is the exact lucide
355
+ * glyph (empty, line visible), active is the bowl filled to the line. Outline
356
+ * → solid mass is the largest silhouette change in the set, and at the
357
+ * segmented control's 16px that is still ~4.7px of rising liquid.
358
+ */
359
+ export function MotionWine(props: MotionIconProps) {
360
+ const clipId = useSvgId("wine-bowl");
361
+
362
+ return (
363
+ <MotionIcon {...props}>
364
+ <defs>
365
+ <clipPath id={clipId}>
366
+ <path d={BOWL} />
367
+ </clipPath>
368
+ </defs>
369
+
370
+ <motion.g variants={glass} style={pivot(12, 22)}>
371
+ <g clipPath={`url(#${clipId})`}>
372
+ <motion.rect
373
+ variants={liquid}
374
+ fill="currentColor"
375
+ stroke="none"
376
+ x="6"
377
+ y={WINE_EMPTY}
378
+ width="12"
379
+ height="12"
380
+ />
381
+ </g>
382
+ <path d="M8 22h8" />
383
+ <path d="M7 10h10" />
384
+ <path d="M12 15v7" />
385
+ <path d={BOWL} />
386
+ </motion.g>
387
+ </MotionIcon>
388
+ );
389
+ }
390
+
391
+ // ─── Map — a tri-fold map is folded shut and snapped open (articulate) ────────
392
+
393
+ const MAP_TIMES = [0, 0.2, 0.5, 0.66, 0.84, 1];
394
+ const FOLD = [1, 0.2, 1.18, 0.94, 1.03, 1];
395
+
396
+ /**
397
+ * The two outer panels fold in against the middle one (right first, left a
398
+ * beat behind), then flap open past flat and settle — what you do with a
399
+ * paper map before reading it. Width 18 → ~8.4 → ~20 → 18.
400
+ *
401
+ * `scaleX` around the fold is a 2D stand-in for a hinge (SVG child 3D
402
+ * transforms are unreliable on iOS Safari); at 22px it reads as "closes, pops
403
+ * open", which is the intent, not as a true fold.
404
+ *
405
+ * Lucide's outline is one closed path; it is cut at the two fold lines,
406
+ * exactly where the glyph already draws `M9 3.236v15` and `M15 5.764v15`.
407
+ * Each fold stroke is drawn on the panel that folds around it, so the seams
408
+ * sit under strokes that are already there at rest.
409
+ */
410
+ const panelRight: Variants = {
411
+ rest: { scaleX: 1, transition: returnTransition },
412
+ active: { scaleX: 1, transition: returnTransition },
413
+ play: { scaleX: FOLD, transition: beats(MAP_TIMES, 0.62) },
414
+ };
415
+ const panelLeft: Variants = {
416
+ rest: { scaleX: 1, transition: returnTransition },
417
+ active: { scaleX: 1, transition: returnTransition },
418
+ play: { scaleX: FOLD, transition: { ...beats(MAP_TIMES, 0.62), delay: 0.06 } },
419
+ };
420
+
421
+ export function MotionMap(props: MotionIconProps) {
422
+ return (
423
+ <MotionIcon {...props}>
424
+ {/* Middle panel — the one in your hand — holds still: the two top/bottom runs and the bumps at the x=15 fold. */}
425
+ <path d="M14.106 5.553a2 2 0 0 0 1.788 0" />
426
+ <path d="M15.894 20.554a2 2 0 0 1-1.788 0l-4.212-2.106" />
427
+ <path d="M9.894 3.447 14.106 5.553" />
428
+ <motion.g variants={panelRight} style={pivot(15, 12)}>
429
+ <path d="M15.894 5.553l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277" />
430
+ <path d="M15 5.764v15" />
431
+ </motion.g>
432
+ <motion.g variants={panelLeft} style={pivot(9, 12)}>
433
+ <path d="M9.894 18.448a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0" />
434
+ <path d="M9 3.236v15" />
435
+ </motion.g>
436
+ </MotionIcon>
437
+ );
438
+ }
439
+
440
+ // ─── Flame — gathers low, flares tall, fills at the peak (deform) ─────────────
441
+
442
+ const FLAME_TIMES = [0, 0.16, 0.42, 0.58, 0.78, 1];
443
+
444
+ /**
445
+ * The trending badge's tap — a gesture, not the loop the badge already runs.
446
+ * The flame crouches low and wide, bursts up tall and thin with its tip
447
+ * whipping sideways, fills solid at the peak of the flare, and settles back
448
+ * to an outline. `skewX` rather than `rotate`: a flame bends in a draft, it
449
+ * does not tilt rigidly. Pivot at the base (lucide's bottom arc is radius 7,
450
+ * so the base is y 22, not 15). The inner tongue shares both endpoints with
451
+ * the outer edge — splitting it would break the closed outline.
452
+ */
453
+ const flame: Variants = {
454
+ rest: { scaleY: 1, scaleX: 1, skewX: 0, fillOpacity: 0, transition: returnTransition },
455
+ active: { scaleY: 1, scaleX: 1, skewX: 0, fillOpacity: 0, transition: returnTransition },
456
+ play: {
457
+ scaleY: [1, 0.8, 1.38, 1.18, 0.96, 1],
458
+ scaleX: [1, 1.15, 0.82, 0.95, 1.03, 1],
459
+ skewX: [0, 0, -8, 7, -2, 0],
460
+ fillOpacity: [0, 0, 1, 1, 0.35, 0],
461
+ transition: beats(FLAME_TIMES),
462
+ },
463
+ };
464
+
465
+ export function MotionFlame(props: MotionIconProps) {
466
+ return (
467
+ <MotionIcon {...props}>
468
+ <motion.path
469
+ variants={flame}
470
+ style={pivot(12, 22)}
471
+ fill="currentColor"
472
+ fillOpacity={0}
473
+ d="M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4"
474
+ />
475
+ </MotionIcon>
476
+ );
477
+ }
478
+
479
+ // ─── Calendar-clock — time runs on the face, and a wedge of it holds ──────────
480
+
481
+ const CLOCK_TIMES = [0, 0.14, 0.62, 0.8, 1];
482
+
483
+ /**
484
+ * The events timeline segment — sibling of `MotionCalendar` (same frame, same
485
+ * posts) with its own behaviour on the face. The minute hand winds back and
486
+ * whips a full turn, and behind it a solid wedge of elapsed time sweeps
487
+ * clockwise from 12, overshoots the hour hand, and settles on it (~4 o'clock,
488
+ * the 122° the lucide hour hand already points at). HOLDS: the wedge stays
489
+ * while the timeline is the active view.
490
+ *
491
+ * Lucide's hands are 2.2 and 1.9 units long — 1.5px at 16px — so the hand's
492
+ * whip is the cause and near-invisible on its own; the wedge is load-bearing.
493
+ * It is a circle of radius 2 stroked 4 wide with butt caps (a stroke as wide
494
+ * as its radius is a pie), rotated so `pathLength` starts at 12 o'clock — the
495
+ * set's way of drawing progress without a `d` morph. Outer radius 4 leaves a
496
+ * ring between wedge and rim so the face still reads as a face.
497
+ */
498
+ const minuteHand: Variants = {
499
+ rest: { rotate: 0, transition: returnTransition },
500
+ active: { rotate: 0, transition: returnTransition },
501
+ play: { rotate: [0, -14, 372, 360, 360], transition: beats(CLOCK_TIMES, 0.62) },
502
+ };
503
+ const elapsed: Variants = {
504
+ rest: { pathLength: 0, transition: returnTransition },
505
+ active: { pathLength: 0.34, transition: returnTransition },
506
+ play: { pathLength: [0, 0, 0.42, 0.31, 0.34], transition: beats(CLOCK_TIMES, 0.62) },
507
+ };
508
+
509
+ export function MotionCalendarClock(props: MotionIconProps) {
510
+ return (
511
+ <MotionIcon {...props}>
512
+ <path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5" />
513
+ <path d="M16 2v4" />
514
+ <path d="M8 2v4" />
515
+ <path d="M3 10h5" />
516
+ <circle cx="16" cy="16" r="6" />
517
+ {/* `pathLength={1}` + `strokeDasharray="0 1"` is exactly what framer
518
+ writes for `pathLength: 0`; without them the first paint — before the
519
+ pose effect runs — is an un-dashed circle, i.e. a solid disc on the
520
+ face for one frame on every mount. Same trap `MotionCheck` guards. */}
521
+ <g transform="rotate(-90 16 16.2)">
522
+ <motion.circle
523
+ variants={elapsed}
524
+ cx="16"
525
+ cy="16.2"
526
+ r="2"
527
+ strokeWidth={4}
528
+ strokeLinecap="butt"
529
+ pathLength={1}
530
+ strokeDasharray="0 1"
531
+ />
532
+ </g>
533
+ <motion.path variants={minuteHand} style={pivot(16, 16.2)} d="M16 14v2.2" />
534
+ <path d="M16 16.2l1.6 1" />
535
+ </MotionIcon>
536
+ );
537
+ }
538
+
539
+ // ─── List — a row is picked up and reinserted at the top (articulate) ─────────
540
+
541
+ const LIST_TIMES = [0, 0.14, 0.62, 0.8, 1];
542
+
543
+ /**
544
+ * The events list segment. The bottom row is pressed, lifts off the page
545
+ * toward the reader, travels up past the other two — which drop a slot each
546
+ * to make way, row 2 first so it clears the mover's path — and lands in the
547
+ * top slot. The end picture is the same three-row list; the values are one
548
+ * slot on, and `useIconGesture` snaps them. Active = rest, like the grid:
549
+ * no held pose survives 16px on this geometry (ragged line lengths, bigger
550
+ * bullets, a shifted row all fall under the floor and read as a defect), so
551
+ * colour and stroke weight carry selection.
552
+ */
553
+ const mover: Variants = {
554
+ rest: { y: 0, x: 0, scale: 1, transition: returnTransition },
555
+ active: { y: 0, x: 0, scale: 1, transition: returnTransition },
556
+ play: {
557
+ y: [0, 1.5, -15.2, -13.6, -14],
558
+ x: [0, 0, 2.5, 0.6, 0],
559
+ scale: [1, 1, 1.06, 1.02, 1],
560
+ transition: beats(LIST_TIMES, 0.6),
561
+ },
562
+ };
563
+ const dropSlot = (delay: number): Variants => ({
564
+ rest: { y: 0, transition: returnTransition },
565
+ active: { y: 0, transition: returnTransition },
566
+ play: { y: [0, 0, 7.6, 6.7, 7], transition: { ...beats([0, 0.22, 0.62, 0.8, 1], 0.6), delay } },
567
+ });
568
+ const rowFirst = dropSlot(0.07);
569
+ const rowSecond = dropSlot(0);
570
+
571
+ function ListRow({ y }: { y: number }) {
572
+ return (
573
+ <>
574
+ <path d={`M3 ${y}h.01`} />
575
+ <path d={`M8 ${y}h13`} />
576
+ </>
577
+ );
578
+ }
579
+
580
+ export function MotionList(props: MotionIconProps) {
581
+ return (
582
+ <MotionIcon {...props}>
583
+ <motion.g variants={rowFirst}>
584
+ <ListRow y={5} />
585
+ </motion.g>
586
+ <motion.g variants={rowSecond}>
587
+ <ListRow y={12} />
588
+ </motion.g>
589
+ <motion.g variants={mover} style={pivot(3, 19)}>
590
+ <ListRow y={19} />
591
+ </motion.g>
592
+ </MotionIcon>
593
+ );
594
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { render } from "@testing-library/react";
2
+ import { act, render } from "@testing-library/react";
3
3
  import * as React from "react";
4
4
 
5
5
  import { MotionIcon, pivot } from "./motion-icon";
@@ -13,8 +13,10 @@ import { MotionIcon, pivot } from "./motion-icon";
13
13
  */
14
14
  const variantsSet: string[] = [];
15
15
  const variantsStarted: string[] = [];
16
+ let prefersReducedMotion = false;
16
17
 
17
18
  vi.mock("framer-motion", () => ({
19
+ useReducedMotion: () => prefersReducedMotion,
18
20
  useAnimationControls: () => ({
19
21
  set: (v: string) => variantsSet.push(v),
20
22
  start: (v: string) => {
@@ -33,6 +35,7 @@ vi.mock("framer-motion", () => ({
33
35
  beforeEach(() => {
34
36
  variantsSet.length = 0;
35
37
  variantsStarted.length = 0;
38
+ prefersReducedMotion = false;
36
39
  });
37
40
 
38
41
  describe("MotionIcon", () => {
@@ -156,6 +159,182 @@ describe("MotionIcon", () => {
156
159
 
157
160
  expect(variantsStarted).toEqual(["play"]);
158
161
  });
162
+
163
+ it("snaps to the pose the gesture depicts once the performance resolves", async () => {
164
+ const { rerender } = render(
165
+ <MotionIcon active={false} replayToken={0}>
166
+ <path />
167
+ </MotionIcon>
168
+ );
169
+ variantsSet.length = 0;
170
+
171
+ // Pointer down, and the navigation it causes flips `active` before the
172
+ // ~600ms gesture finishes.
173
+ rerender(
174
+ <MotionIcon active={false} replayToken={1}>
175
+ <path />
176
+ </MotionIcon>
177
+ );
178
+ rerender(
179
+ <MotionIcon active replayToken={1}>
180
+ <path />
181
+ </MotionIcon>
182
+ );
183
+ await act(async () => {});
184
+
185
+ // The grid's tiles end one slot around the board, the calendar's reel four
186
+ // rows up: same picture, different values. Without this snap the next
187
+ // `rest` would tween them back and perform the gesture in reverse.
188
+ expect(variantsStarted).toEqual(["play"]);
189
+ expect(variantsSet).toEqual(["active"]);
190
+ });
191
+
192
+ it("holds the end pose for a grace period, then snaps to rest when the state never arrives", async () => {
193
+ vi.useFakeTimers();
194
+ try {
195
+ const { rerender } = render(
196
+ <MotionIcon active={false} replayToken={0}>
197
+ <path />
198
+ </MotionIcon>
199
+ );
200
+ variantsSet.length = 0;
201
+
202
+ rerender(
203
+ <MotionIcon active={false} replayToken={1}>
204
+ <path />
205
+ </MotionIcon>
206
+ );
207
+ await act(async () => {});
208
+ // Resolved, but a route may still be confirming: nothing yet.
209
+ expect(variantsSet).toEqual([]);
210
+
211
+ await act(async () => {
212
+ vi.advanceTimersByTime(900);
213
+ });
214
+ expect(variantsSet).toEqual(["rest"]);
215
+ } finally {
216
+ vi.useRealTimers();
217
+ }
218
+ });
219
+
220
+ it("takes the held pose without a second performance when `active` arrives late", async () => {
221
+ vi.useFakeTimers();
222
+ try {
223
+ const { rerender } = render(
224
+ <MotionIcon active={false} replayToken={0}>
225
+ <path />
226
+ </MotionIcon>
227
+ );
228
+ variantsSet.length = 0;
229
+ variantsStarted.length = 0;
230
+
231
+ rerender(
232
+ <MotionIcon active={false} replayToken={1}>
233
+ <path />
234
+ </MotionIcon>
235
+ );
236
+ await act(async () => {});
237
+ // A slow route: the gesture has landed, the state lands 850ms after the tap.
238
+ await act(async () => {
239
+ vi.advanceTimersByTime(850);
240
+ });
241
+ rerender(
242
+ <MotionIcon active replayToken={1}>
243
+ <path />
244
+ </MotionIcon>
245
+ );
246
+ await act(async () => {
247
+ vi.advanceTimersByTime(2000);
248
+ });
249
+
250
+ // One play, one snap to active — no cut to rest in between, no replay.
251
+ expect(variantsStarted).toEqual(["play"]);
252
+ expect(variantsSet).toEqual(["active"]);
253
+ } finally {
254
+ vi.useRealTimers();
255
+ }
256
+ });
257
+
258
+ it("lets an in-flight gesture land before returning, instead of springing it back mid-slide", async () => {
259
+ vi.useFakeTimers();
260
+ try {
261
+ const { rerender } = render(
262
+ <MotionIcon active replayToken={0}>
263
+ <path />
264
+ </MotionIcon>
265
+ );
266
+ variantsSet.length = 0;
267
+ variantsStarted.length = 0;
268
+
269
+ // Tap the current tab (replay), then leave for another within 600ms.
270
+ rerender(
271
+ <MotionIcon active replayToken={1}>
272
+ <path />
273
+ </MotionIcon>
274
+ );
275
+ rerender(
276
+ <MotionIcon active={false} replayToken={1}>
277
+ <path />
278
+ </MotionIcon>
279
+ );
280
+ await act(async () => {});
281
+ await act(async () => {
282
+ vi.advanceTimersByTime(900);
283
+ });
284
+
285
+ // No `rest` tween was STARTED (that would reverse the grid's tiles);
286
+ // the settle snapped the values instead.
287
+ expect(variantsStarted).toEqual(["play"]);
288
+ expect(variantsSet).toEqual(["rest"]);
289
+ } finally {
290
+ vi.useRealTimers();
291
+ }
292
+ });
293
+
294
+ it("under reduced motion skips the performance but still changes state", async () => {
295
+ prefersReducedMotion = true;
296
+ const { rerender } = render(
297
+ <MotionIcon active={false} replayToken={0}>
298
+ <path />
299
+ </MotionIcon>
300
+ );
301
+ variantsSet.length = 0;
302
+
303
+ rerender(
304
+ <MotionIcon active replayToken={1}>
305
+ <path />
306
+ </MotionIcon>
307
+ );
308
+ await act(async () => {});
309
+
310
+ // Less movement, not less feedback: no `play`, straight to the held pose.
311
+ expect(variantsStarted).toEqual([]);
312
+ expect(variantsSet).toEqual(["active"]);
313
+ });
314
+
315
+ it("only the latest of two overlapping performances gets to settle", async () => {
316
+ const { rerender } = render(
317
+ <MotionIcon active={false} replayToken={0}>
318
+ <path />
319
+ </MotionIcon>
320
+ );
321
+ variantsSet.length = 0;
322
+
323
+ rerender(
324
+ <MotionIcon active={false} replayToken={1}>
325
+ <path />
326
+ </MotionIcon>
327
+ );
328
+ rerender(
329
+ <MotionIcon active replayToken={2}>
330
+ <path />
331
+ </MotionIcon>
332
+ );
333
+ await act(async () => {});
334
+
335
+ expect(variantsStarted).toEqual(["play", "play"]);
336
+ expect(variantsSet).toEqual(["active"]);
337
+ });
159
338
  });
160
339
 
161
340
  /**