@clicktap/ui 0.40.8 → 0.40.9

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 (22) hide show
  1. package/docs/migrations/2026/01/2026-01-14-external-link-detection.md +290 -0
  2. package/docs/migrations/2026/01/2026-01-14-use-route-hook.md +142 -0
  3. package/docs/migrations/2026/01/2026-01-15-input-disable-skeleton.md +82 -0
  4. package/docs/migrations/2026/02/2026-02-16-env-utils-from-clicktap-ui.md +94 -0
  5. package/docs/migrations/2026/02/2026-02-17-select-popover-portal-container-restored.md +84 -0
  6. package/docs/migrations/2026/03/2026-03-24-image-dimmed-hidden-onready.md +108 -0
  7. package/docs/migrations/2026/03/2026-03-24-modal-overlay-backdrop-dismiss.md +81 -0
  8. package/docs/migrations/2026/04/2026-04-17-select-address-input-onchange-key-or-item.md +182 -0
  9. package/docs/migrations/2026/04/2026-04-24-image-placeholder-frontend-fallback.md +85 -0
  10. package/docs/migrations/2026/04/2026-04-30-prune-invalid-pdp-selections.md +166 -0
  11. package/docs/migrations/2026/04/2026-04-30-top-down-pdp-option-dependency.md +286 -0
  12. package/docs/migrations/2026/05/2026-05-01-build-pdp-url-util.md +128 -0
  13. package/docs/migrations/2026/05/2026-05-03-auth-aware-graphql-client.md +278 -0
  14. package/docs/migrations/2026/05/2026-05-20-framer-motion-11-idioms.md +391 -0
  15. package/docs/migrations/2026/05/2026-05-21-button-loading-content.md +265 -0
  16. package/docs/migrations/2026/05/2026-05-21-password-check-hide-on-empty.md +50 -0
  17. package/docs/migrations/2026/05/2026-05-29-password-check-requirements-variant.md +63 -0
  18. package/docs/migrations/2026/06/2026-06-17-select-standardization.md +286 -0
  19. package/docs/migrations/2026/06/2026-06-18-select-hiddenselect-setvalue-crash.md +75 -0
  20. package/docs/migrations/2026/07/2026-07-31-ui-exports-bundler-resolution.md +50 -0
  21. package/docs/migrations/README.md +31 -0
  22. package/package.json +1 -1
@@ -0,0 +1,391 @@
1
+ # framer-motion 11.x idioms — opacity-only for modal entry/exit, primitives over transform strings, matched keyframe shapes
2
+
3
+ **Date:** 2026-05-20
4
+ **Affects:** `@clicktap/ui/components/Dialog`, `@clicktap/ui/components/ModalOverlay`, `@clicktap/ui/components/Badge`, `@clicktap/ui/components/Loader/CircularEasing`, `@clicktap/ui/components/Loader/Pulse`, and any consumer-side `motion.*` usage on framer-motion 11.x.
5
+ **Classification:** COMPATIBILITY
6
+ **Breaking:** Yes (for consumers passing custom `animationVariants` containing multi-function `transform` strings, transform/scale primitives in modal variants, calc-with-mixed-units in animate targets, or who copy-paste the affected motion patterns into their own components — see Consumer Impact)
7
+
8
+ ## Dependencies
9
+
10
+ *None.* Targets framer-motion 11.18.
11
+
12
+ ## Summary
13
+
14
+ framer-motion 11.x's keyframe resolver crashes inside `mixObject` (`complex.mjs:48`) with `Cannot read properties of null (reading '0')` more often than the docs suggest. The root cause is `getMixer(null)`: `typeof null === 'object'` in JavaScript, so when the resolver hands a null keyframe value into the mixer factory it returns `mixObject`, and `mixObject` then dereferences a key on null and throws. Any time the resolver leaves a null in the keyframes array — unresolved DOM-dependent value, a key animated in one phase but missing in another, a complex/calc value that fails to parse, an un-interpolatable CSS property batched with other unresolved keyframes — it crashes here.
15
+
16
+ We hit this bug repeatedly across the UI library and the frontend app. After iterating on several "use primitives instead of transform strings" attempts that *still* crashed under realistic ancestor-AnimatePresence load, we landed on a small set of rules that hold:
17
+
18
+ 1. **Modal entry/exit animations: animate `opacity` only.** Centering, scale, and any other transform-related styling stays in static CSS (Tailwind). The Dialog and ModalOverlay default variants now look like `{ opacity: 0 } ↔ { opacity: 1 }`, full stop.
19
+ 2. **For non-modal motion (toasts, badges, page-level enter/exit), prefer framer-motion primitives (`x`, `y`, `rotate`, `scale`, `opacity`) over multi-function CSS `transform` strings.** Primitives compose into a single transform that framer-motion owns; transform strings go through the complex-value parser, which is the buggy code path.
20
+ 3. **Every key that `animate` touches must also be present in `initial` and `exit`.** When you have a no-animation branch (`shouldAnimate ? animatedShape : skippedShape`), the skipped shape still has to include the same keys with sensible base values (usually `0`).
21
+ 4. **Resolve unit math to pixels in JS before handing it to framer-motion.** Strings like `calc(-100% + Npx + Yrem)` are complex values; the parser can't reliably mix them across remeasure cycles.
22
+ 5. **Anything WAAPI can't interpolate goes on a static CSS class, not in `variants`.** `backdrop-filter`, complex `clip-path`, gradient `background`, `pointer-events`, etc.
23
+
24
+ Until framer-motion 12.x lands (where this resolver is rewritten), treat these rules as the long-term convention, not a workaround.
25
+
26
+ ## Problem
27
+
28
+ A timeline of what failed before we got to the right rule set:
29
+
30
+ 1. **Dialog rendered off-center** — framer-motion was writing its own inline `transform` for `scale`, overwriting Tailwind `-translate-x-1/2 -translate-y-1/2` centering classes.
31
+ 2. **`mixObject` crash on the Cybersource / Auth.Net checkout step** — saved-cards ↔ new-card-form toggle exposed two contributing causes: Dialog variants used multi-function `transform` strings, AND the `motion.div` toggling between forms had mismatched `initial` / `animate` / `exit` keys.
32
+ 3. **`mixObject` crash on the Add Address modal** — Dialog had been "fixed" with primitives at this point, but the Badge cart-count component still animated `transform: 'scale(0/1)'` strings. Ancestor remeasure from the modal open triggered Badge's variants through the complex parser.
33
+ 4. **Loader spinners crash similarly** — `CircularEasing` animated `transform: 'rotate(360deg)'` and `Pulse` animated `transform: ['scale(...)', ...]` array. Same complex-parser path.
34
+ 5. **Toast position crash** — `Toast.tsx` animated `translateY: 'calc(-100% + Npx + Yrem)'` — a calc with mixed units (`%`, `px`, `rem`). The parser couldn't keep up across re-runs on layout shifts.
35
+ 6. **Add Address modal *still* crashed** even with all the above fixed and Dialog variants on `x`/`y`/`scale` primitives. The final cause turned out to be the modal's own variants — `x: '-50%'` primitives still routed through the complex-value path under enough load. Once we dropped to opacity-only, the crash stopped.
36
+
37
+ The pattern is consistent: framer-motion 11.x's keyframe resolver gets confused when batched keyframe resolution sees any kind of non-trivial value (string with units, calc, multi-function transform, pointer-events, etc.). Opacity is reliably safe.
38
+
39
+ ## Changes
40
+
41
+ | File | Change |
42
+ |---|---|
43
+ | `libs/ui/src/components/Dialog/Dialog.tsx` | Default `animationVariants` now `{ opacity: 0 } ↔ { opacity: 1 }` — no scale, no x/y. Centering restored to static Tailwind classes (`-translate-x-1/2 -translate-y-1/2`). |
44
+ | `libs/ui/src/components/ModalOverlay/ModalOverlay.tsx` | Default variants reduced to opacity-only; `pointerEvents` removed from variants. Backdrop blur stays on the static CSS class (already was). |
45
+ | `libs/ui/src/components/Badge/Badge.tsx` | `animationVariants` switched from `transform: 'scale(0/1)'` strings to `scale: 0 / 1` primitives. |
46
+ | `libs/ui/src/components/Loader/CircularEasing.tsx` | Spinner `animate` switched from `transform: 'rotate(360deg)'` to `rotate: 360` primitive. |
47
+ | `libs/ui/src/components/Loader/Pulse.tsx` | Pulse keyframes switched from `transform: ['scale(1)', 'scale(0.1)', ...]` to `scale: [1, 0.1, ...]` numeric keyframes. |
48
+ | `apps/frontend/components/Toast/Toast.tsx` | Container `translateY` calc string replaced with a numeric pixel `y` value. Container-height and rem-to-pixel resolved in JS before handing to framer-motion. |
49
+ | `apps/frontend/components/Toast/Notification.tsx` | `initial` / `animate` / `exit` shapes aligned (all three include `opacity`, `x`, `y`). `translateX`/`translateY` aliases dropped in favor of `x`/`y` primitives. |
50
+ | `apps/frontend/components/Checkout/Step/Payment/Cybersource/CybersourcePaymentMethod.tsx` | The no-animate branch of `initial` previously returned `{ opacity: 1 }` without `x`. Both branches now include `x: 0`, and `exit` includes `x: 0`. |
51
+ | `apps/frontend/components/Checkout/Step/Payment/Authorizenet/AuthorizenetPaymentMethod.tsx` | Same shape-consistency fix as Cybersource. |
52
+
53
+ ### Dialog variants — final state
54
+
55
+ ```tsx
56
+ // before — multi-function transform string; off-center; crashes under load
57
+ variants={
58
+ animationVariants || {
59
+ hidden: { transform: 'translate(-50%, -50%) scale(0.8)', opacity: 0 },
60
+ visible: { transform: 'translate(-50%, -50%) scale(1)', opacity: 1 },
61
+ }
62
+ }
63
+ ```
64
+
65
+ ```tsx
66
+ // after — opacity only; centering moved to static Tailwind
67
+ className={cn(
68
+ 'p-8 outline-0 max-w-max w-screen absolute top-2/4 left-2/4',
69
+ '-translate-x-1/2 -translate-y-1/2', // ← centering back in CSS
70
+ 'shadow-... rounded-lg bg-white border ...',
71
+ className,
72
+ )}
73
+ // ...
74
+ variants={
75
+ animationVariants || {
76
+ hidden: { opacity: 0 },
77
+ visible: { opacity: 1 },
78
+ }
79
+ }
80
+ ```
81
+
82
+ ### ModalOverlay variants — final state
83
+
84
+ ```tsx
85
+ // before
86
+ variants={
87
+ animationVariants || {
88
+ hidden: {
89
+ opacity: 0,
90
+ pointerEvents: 'none', // ← non-interpolatable; risky
91
+ transition: { delay: 0.08 },
92
+ },
93
+ visible: { opacity: 1, pointerEvents: 'auto' },
94
+ }
95
+ }
96
+ ```
97
+
98
+ ```tsx
99
+ // after — opacity only; pointerEvents handled via static class on the overlay
100
+ variants={
101
+ animationVariants || {
102
+ hidden: { opacity: 0, transition: { delay: 0.08 } },
103
+ visible: { opacity: 1 },
104
+ }
105
+ }
106
+ ```
107
+
108
+ ### Badge variants — primitive swap
109
+
110
+ ```tsx
111
+ // before
112
+ const animationVariants = {
113
+ hidden: { opacity: 0, transform: 'scale(0)' },
114
+ show: { opacity: 1, transform: 'scale(1)' },
115
+ };
116
+ ```
117
+
118
+ ```tsx
119
+ // after
120
+ const animationVariants = {
121
+ hidden: { opacity: 0, scale: 0 },
122
+ show: { opacity: 1, scale: 1 },
123
+ };
124
+ ```
125
+
126
+ ### Loader spinners — primitive swap
127
+
128
+ ```tsx
129
+ // CircularEasing — before
130
+ <motion.svg animate={{ transform: 'rotate(360deg)', transition: {...} }} />
131
+
132
+ // CircularEasing — after
133
+ <motion.svg animate={{ rotate: 360, transition: {...} }} />
134
+ ```
135
+
136
+ ```tsx
137
+ // Pulse — before
138
+ <motion.div animate={{
139
+ opacity: [1, 1, 0.7, 1, 1],
140
+ transform: ['scale(1)', 'scale(1)', 'scale(0.1)', 'scale(1)', 'scale(1)'],
141
+ transition: {...},
142
+ }} />
143
+
144
+ // Pulse — after
145
+ <motion.div animate={{
146
+ opacity: [1, 1, 0.7, 1, 1],
147
+ scale: [1, 1, 0.1, 1, 1],
148
+ transition: {...},
149
+ }} />
150
+ ```
151
+
152
+ ### Toast container — calc → numeric pixels
153
+
154
+ ```tsx
155
+ // before — calc with mixed units; complex parser stumbles on remeasure
156
+ const h = toastRef.current?.children?.item(y === 'top' ? notifications.length - 1 : 0)
157
+ ?.getBoundingClientRect().height;
158
+ const translateY = isMobile
159
+ ? { translateY: mobileY === 'top'
160
+ ? `calc(-100% + ${h ?? 0}px + 1rem)`
161
+ : `calc(100% - ${h ?? 0}px - 1rem)` }
162
+ : { translateY: y === 'top'
163
+ ? `calc(-100% + ${h ?? 0}px + 8rem)`
164
+ : `calc(100% - ${h ?? 0}px - 3.5rem)` };
165
+ toastControls.set(translateY);
166
+ toastControls.start(translateY);
167
+ ```
168
+
169
+ ```tsx
170
+ // after — resolve to a single number, hand framer-motion a primitive
171
+ const h = toastRef.current?.children?.item(y === 'top' ? notifications.length - 1 : 0)
172
+ ?.getBoundingClientRect().height ?? 0;
173
+ const containerHeight =
174
+ toastRef.current?.getBoundingClientRect().height ?? 0;
175
+ const remPx =
176
+ typeof window !== 'undefined'
177
+ ? parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
178
+ : 16;
179
+ const translateYValue = isMobile
180
+ ? mobileY === 'top'
181
+ ? -containerHeight + h + 1 * remPx
182
+ : containerHeight - h - 1 * remPx
183
+ : y === 'top'
184
+ ? -containerHeight + h + 8 * remPx
185
+ : containerHeight - h - 3.5 * remPx;
186
+ const translateY = { y: translateYValue };
187
+ toastControls.set(translateY);
188
+ toastControls.start(translateY);
189
+ ```
190
+
191
+ ### Notification — shape consistency across initial/animate/exit
192
+
193
+ ```tsx
194
+ // before — translateY/translateX aliases, exit drops the transform keys entirely
195
+ const initial = isMobile
196
+ ? { opacity: 0, translateY: '200px' }
197
+ : { opacity: 0, translateX: '200px' };
198
+ const animate = isMobile
199
+ ? { opacity: 1, translateY: 0 }
200
+ : { opacity: 1, translateX: 0 };
201
+ // ...
202
+ <motion.div initial={initial} animate={animate} exit={{ opacity: 0 }}>
203
+ ```
204
+
205
+ ```tsx
206
+ // after — primitives, same keys in all three phases
207
+ const initial = isMobile
208
+ ? { opacity: 0, x: 0, y: 200 }
209
+ : { opacity: 0, x: 200, y: 0 };
210
+ const animate = { opacity: 1, x: 0, y: 0 };
211
+ const exit = { opacity: 0, x: 0, y: 0 };
212
+ // ...
213
+ <motion.div initial={initial} animate={animate} exit={exit}>
214
+ ```
215
+
216
+ ### `initial` / `animate` / `exit` shape consistency in app code
217
+
218
+ Same fix applies to consumer-side `motion.div` usage. From `CybersourcePaymentMethod`:
219
+
220
+ ```tsx
221
+ // before — shouldAnimate=false branch omits x, but animate sets x:0; exit drops x too
222
+ <motion.div
223
+ key="newCardForm"
224
+ initial={shouldAnimate ? { opacity: 0, x: 32 } : { opacity: 1 }}
225
+ animate={{ opacity: 1, x: 0 }}
226
+ exit={{ opacity: 0 }}
227
+ />
228
+ ```
229
+
230
+ ```tsx
231
+ // after — every key in animate is also in both initial branches and exit
232
+ <motion.div
233
+ key="newCardForm"
234
+ initial={shouldAnimate ? { opacity: 0, x: 32 } : { opacity: 1, x: 0 }}
235
+ animate={{ opacity: 1, x: 0 }}
236
+ exit={{ opacity: 0, x: 0 }}
237
+ />
238
+ ```
239
+
240
+ ## Consumer Impact
241
+
242
+ Five categories of code in consuming projects to audit:
243
+
244
+ | Pattern | Required change |
245
+ |---|---|
246
+ | Custom `animationVariants` on `Dialog` / `ModalOverlay` containing **any** non-opacity property (scale, x/y primitives, transform strings, pointer-events, etc.) | Strip to `{ opacity: 0/1 }` and move all other styling to static CSS classes. |
247
+ | Custom `animationVariants` on `Badge`, loaders, or other non-modal components using multi-function `transform` strings | Split into framer-motion primitives (`x`, `y`, `scale`, `rotate`). |
248
+ | Local `motion.div` / `motion.section` / etc. where one of `initial` / `animate` / `exit` includes a key the others don't | Add the missing key with a sensible base value (usually `0`) to the other branches. |
249
+ | `motion.*` variants animating `calc(...)` strings with mixed units, or other complex CSS-value strings | Resolve to numeric pixels in JS first; hand framer-motion a number. |
250
+ | `motion.*` variants animating `backdrop-filter`, `clip-path`, `filter`, gradients, `pointer-events`, or any WAAPI-incompatible property | Move the value to a static CSS class; only animate `opacity` (or a primitive) to fade them on/off. |
251
+
252
+ Search commands:
253
+
254
+ ```bash
255
+ # Multi-function transform strings in motion code
256
+ grep -rn "transform:.*translate\|transform:.*scale\|transform:.*rotate" apps/ libs/ \
257
+ | grep -v vendor | grep -v node_modules
258
+
259
+ # Possibly-mismatched initial/animate shapes — look for `initial=` lines that
260
+ # differ in length from neighboring animate/exit lines
261
+ grep -rnA2 "initial=" apps/ libs/ | grep -v vendor | grep -v node_modules
262
+
263
+ # Custom variants for @clicktap/ui Dialog/Modal — any non-opacity prop here is suspect
264
+ grep -rn "animationVariants" apps/ libs/ | grep -v vendor
265
+
266
+ # calc() strings inside motion code
267
+ grep -rn "calc(" apps/ libs/ | grep -v node_modules | grep -v vendor | grep -i 'motion\|controls\|animate\|translate'
268
+
269
+ # WAAPI-incompatible CSS properties in motion variants
270
+ grep -rn "backdropFilter\|backdrop-filter\|clipPath\|clip-path\|pointerEvents:\s*'" apps/ libs/ | grep -v vendor
271
+ ```
272
+
273
+ ## Pattern for Consumers
274
+
275
+ The five rules below cover every framer-motion 11.x crash pattern we found. Apply them to any `motion.*` element or component receiving `variants` / `animate`.
276
+
277
+ **Rule 1 — Modal entry/exit: opacity only.** For `Dialog`, `ModalOverlay`, `Drawer`, and any other modal-like component, the entry/exit animation should be a fade. Everything else (centering, slide direction, scale) lives in static CSS. The default `@clicktap/ui` variants now follow this.
278
+
279
+ **Rule 2 — Primitives over transform strings.** For non-modal `motion.*` elements, prefer framer-motion primitives over `transform: '...'` strings.
280
+
281
+ | Avoid | Use |
282
+ |---|---|
283
+ | `transform: 'translateX(-50%)'` | `x: '-50%'` |
284
+ | `transform: 'translateY(10px)'` | `y: 10` |
285
+ | `transform: 'scale(0.9)'` | `scale: 0.9` |
286
+ | `transform: 'rotate(45deg)'` | `rotate: 45` |
287
+ | `transform: 'translate(-50%, -50%) scale(0.8)'` | `x: '-50%', y: '-50%', scale: 0.8` |
288
+ | `translateY: '200px'` (alias) | `y: 200` |
289
+
290
+ **Rule 3 — Keyframe shapes must match.** If `animate` sets `{ opacity, x, scale }`, then `initial` and `exit` must each include all three keys. The framer-motion docs allow omission in theory; in practice the 11.x keyframe resolver stores nulls and crashes on remeasure.
291
+
292
+ ```tsx
293
+ // REFERENCE — same shape across initial/animate/exit
294
+ <motion.div
295
+ initial={{ opacity: 0, x: 32 }}
296
+ animate={{ opacity: 1, x: 0 }}
297
+ exit={{ opacity: 0, x: 0 }}
298
+ />
299
+ ```
300
+
301
+ If you need a "no animation" branch, the skipped shape still has to include the same keys:
302
+
303
+ ```tsx
304
+ // REFERENCE — branched initial that still matches the target shape
305
+ <motion.div
306
+ initial={shouldAnimate ? { opacity: 0, x: 32 } : { opacity: 1, x: 0 }}
307
+ animate={{ opacity: 1, x: 0 }}
308
+ exit={{ opacity: 0, x: 0 }}
309
+ />
310
+ ```
311
+
312
+ **Rule 4 — Resolve unit math in JS, not in motion values.** Don't hand framer-motion calc strings with mixed units. Compute pixels in the consumer and pass a number.
313
+
314
+ ```tsx
315
+ // AVOID
316
+ toastControls.start({ translateY: `calc(-100% + ${h}px + 8rem)` });
317
+
318
+ // USE
319
+ const remPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
320
+ const containerHeight = ref.current?.getBoundingClientRect().height ?? 0;
321
+ toastControls.start({ y: -containerHeight + h + 8 * remPx });
322
+ ```
323
+
324
+ **Rule 5 — Un-interpolatable CSS goes on a static class.** `backdrop-filter`, complex `clip-path`, gradient `background`, `pointer-events`, etc. Don't put these in variants. Put them in a Tailwind class (or a `style` literal) and animate only `opacity` to fade them in/out. The `@clicktap/ui` `ModalOverlay` does this for `backdrop-blur-sm` — copy that pattern.
325
+
326
+ ## Migration Steps
327
+
328
+ 1. **Update `@clicktap/ui`** to a version that includes all of: Dialog opacity-only, ModalOverlay opacity-only, Badge primitive, Loader primitives (≥ this change).
329
+
330
+ 2. **If your project overrides `animationVariants` on Dialog or ModalOverlay** with anything beyond opacity, simplify:
331
+
332
+ ```tsx
333
+ // before — risky: x/y primitives still route through the complex parser
334
+ // under enough ancestor-AnimatePresence load
335
+ <Dialog
336
+ animationVariants={{
337
+ hidden: { x: '-50%', y: '-50%', scale: 0.9, opacity: 0 },
338
+ visible: { x: '-50%', y: '-50%', scale: 1, opacity: 1 },
339
+ }}
340
+ />
341
+ ```
342
+
343
+ ```tsx
344
+ // after — strip to opacity; centering moves to a wrapper className
345
+ <Dialog
346
+ animationVariants={{
347
+ hidden: { opacity: 0 },
348
+ visible: { opacity: 1 },
349
+ }}
350
+ className="-translate-x-1/2 -translate-y-1/2 ..."
351
+ />
352
+ ```
353
+
354
+ 3. **Audit local `motion.*` usage in your app** for the five rules. For each `motion.div` (or `motion.section` etc.):
355
+ - Strip any non-opacity values out of the modal variants if it's a modal-shaped component.
356
+ - Replace `transform: '...'` strings with primitives.
357
+ - Match `initial` / `animate` / `exit` shapes — every key in `animate` must be in `initial` and `exit`.
358
+ - Resolve calc-with-units to pixel numbers in JS.
359
+ - Move backdrop-filter / pointer-events / clip-path / gradients to static CSS classes.
360
+
361
+ 4. **Verify.**
362
+ - Run the dev server and open every modal flow your project supports (cart drawer, address book Add/Edit, checkout payment-method toggle, custom dialogs). No console errors referencing `mixObject` or `complex.mjs`.
363
+ - Open and close drawers/modals rapidly while a toast is mid-animation. No console errors.
364
+ - Visit a page that renders the cart-count Badge while opening a modal. The Badge animation should play smoothly with no console error.
365
+ - Run typecheck:
366
+ ```bash
367
+ docker compose exec -u app node-app sh -c 'cd /app && npx nx run frontend:typecheck'
368
+ ```
369
+
370
+ ## Rollback
371
+
372
+ Reverting any of the framer-motion 11 changes is safe — they're behavioral fixes with no schema or data implications. Down-migration: re-introduce the transform strings or trimmed `initial` shapes in the affected files. Note that the crash returns once you do — the framer-motion 11.x parser hasn't changed.
373
+
374
+ ## Files Changed
375
+
376
+ ```
377
+ libs/ui/src/components/Dialog/Dialog.tsx
378
+ libs/ui/src/components/ModalOverlay/ModalOverlay.tsx
379
+ libs/ui/src/components/Badge/Badge.tsx
380
+ libs/ui/src/components/Loader/CircularEasing.tsx
381
+ libs/ui/src/components/Loader/Pulse.tsx
382
+ apps/frontend/components/Toast/Toast.tsx
383
+ apps/frontend/components/Toast/Notification.tsx
384
+ apps/frontend/components/Checkout/Step/Payment/Cybersource/CybersourcePaymentMethod.tsx
385
+ apps/frontend/components/Checkout/Step/Payment/Authorizenet/AuthorizenetPaymentMethod.tsx
386
+ ```
387
+
388
+ ## References
389
+
390
+ - framer-motion `mixObject` source: `node_modules/framer-motion/dist/es/utils/mix/complex.mjs:48` — `if (a[key] !== undefined && b[key] !== undefined)` dereferences a key on `null` because `typeof null === 'object'` made `getMixer(null)` return `mixObject`.
391
+ - The framer-motion 11.x keyframe resolver is the upstream root cause; tracked in framer-motion's own issue tracker. 12.x rewrites this path. Adopt the rules above as long-term convention regardless of upgrade timing.
@@ -0,0 +1,265 @@
1
+ # `ButtonLoadingContent` helper — standardized right-side spinner for all submit buttons
2
+
3
+ **Date:** 2026-05-21
4
+ **Affects:** `@clicktap/ui/components/Button` (new `ButtonLoadingContent` export) and every submit-style button in the frontend app.
5
+ **Classification:** IMPROVEMENT
6
+ **Breaking:** No
7
+
8
+ ## Dependencies
9
+
10
+ *None.*
11
+
12
+ ## Summary
13
+
14
+ A new `ButtonLoadingContent` helper exported from `@clicktap/ui/components/Button` provides the canonical "label + right-side spinner" layout for any submit button. The spinner fades in via opacity (no layout shift), an invisible spacer on the left keeps the label visually centered in both states, and the label stays visible the whole time. This pattern is now used everywhere a submit button needs to show progress — Place Order, Continue (checkout step actions), Sign in / Sign up / Reset password, Save Address, Update / Remove / Add payment method, PDP Add to cart, etc.
15
+
16
+ Before this change, submit buttons across the app inconsistently mixed:
17
+ - text swaps (`isSubmitting ? 'Updating…' : 'Update'`),
18
+ - spinner-only (`isSubmitting ? <CircularEasing /> : 'Continue'`),
19
+ - inline copy of the Place-Order span/spacer/spinner pattern (`<span className="inline-flex ..."><span className="w-5" /> Place order <span className={isLoading ? 'opacity-100' : 'opacity-0'}>...`),
20
+ - nothing at all.
21
+
22
+ They now all use `ButtonLoadingContent`.
23
+
24
+ ## Why
25
+
26
+ Two reasons converged:
27
+
28
+ 1. **Inconsistent UX.** Some buttons swapped to "Updating…" text; others showed a spinner instead of the label; some did both; some did neither. There was no single canonical pattern, so visitors got different feedback styles depending on which surface they were on.
29
+
30
+ 2. **A real bug behind the inconsistency.** The Continue-to-review button in checkout was set up to show a loader during step submission, but the Cybersource and Auth.Net `NewCardForm` event handlers were flipping `isSubmitting` back to `false` immediately after dispatching the form-submit event. The label stayed visible (or no loader appeared) during the entire save-card mutation; the loader only reappeared when the state machine reached `submittingStepData`. Standardizing on the helper also forced fixing that — the save-card path now keeps the spinner visible from click through save-card → step-submit.
31
+
32
+ ## Changes
33
+
34
+ | File | Change |
35
+ |---|---|
36
+ | `libs/ui/src/components/Button/ButtonLoadingContent.tsx` | **New.** The helper component (see API below). |
37
+ | `libs/ui/src/components/Button/index.ts` | Adds `ButtonLoadingContent` to the exports. |
38
+ | `apps/frontend/components/Checkout/Step/Payment/Cybersource/NewCardForm.tsx` | Removed the premature `setIsSubmitting(false)` inside the `paymentMethodSubmit` event handler — the spinner now stays visible through the entire save-card + step-submit flow. |
39
+ | `apps/frontend/components/Checkout/Step/Payment/Authorizenet/NewCardForm.tsx` | Same fix as Cybersource. |
40
+ | `apps/frontend/components/Checkout/Step/StepActions.tsx` | `Continue to review` button: replaced spinner-only-when-loading with `<ButtonLoadingContent>{nextLabel}</ButtonLoadingContent>`. |
41
+ | `apps/frontend/components/Checkout/Step/Review/CheckoutButton.tsx` | The three `Place order` buttons (Cybersource / Auth.Net / NO_PAYMENT_REQUIRED) replaced their hand-rolled label+spacer+spinner spans with `<ButtonLoadingContent>`. |
42
+ | `apps/frontend/components/Checkout/Step/Review/CheckoutButton/OfflineCheckoutButton.tsx` | Same. |
43
+ | `apps/frontend/components/Product/components/AddToCart/AddToCart.tsx` | PDP Add-to-cart now wraps its label in `ButtonLoadingContent` with `isLoading={isUpdatingItems}`. |
44
+ | `apps/frontend/components/Checkout/Step/Payment/Cybersource/SavedCardsForm/SavedCard/EditButton/UpdatePayment.tsx`, `.../Authorizenet/...UpdatePayment.tsx`, `.../Account/PaymentMethods/Cybersource/PaymentMethod/EditButton/UpdatePayment.tsx`, `.../Authorizenet/...UpdatePayment.tsx` | `isSubmitting ? 'Updating…' : 'Update'` → `<ButtonLoadingContent isLoading={isSubmitting}>Update</ButtonLoadingContent>`. |
45
+ | `apps/frontend/components/Checkout/Step/Payment/Cybersource/SavedCardsForm/SavedCard/DeleteButton.tsx`, `.../Authorizenet/.../DeleteButton.tsx` | `isRemoving ? 'Removing…' : 'Remove'` → `<ButtonLoadingContent isLoading={isRemoving}>Remove</ButtonLoadingContent>`. |
46
+ | `apps/frontend/components/Account/PaymentMethods/Cybersource/CreateNewPayment.tsx`, `.../Authorizenet/CreateNewPayment.tsx` | `isSubmitting ? 'Adding payment method' : 'Add payment method'` → `<ButtonLoadingContent>`. |
47
+ | `apps/frontend/components/Account/Address/Create.tsx` | `isSubmitting ? <>Saving Address <CircularEasing/></> : 'Save Address'` → `<ButtonLoadingContent isLoading={isSubmitting}>Save Address</ButtonLoadingContent>`. |
48
+ | `apps/frontend/components/Account/Address/Edit.tsx` | `isSubmitting ? 'Updating' : 'Update'` → `<ButtonLoadingContent>`. |
49
+ | `apps/frontend/components/Account/Auth/Authenticate/Signup.tsx` | `isSubmitting ? 'Signing up' : 'Sign up'` → `<ButtonLoadingContent>`. |
50
+ | `apps/frontend/components/Account/Auth/Authenticate/Signin.tsx` | `isSubmitting || isSubmitted ? 'Signing in' : 'Sign in'` → `<ButtonLoadingContent isLoading={isSubmitting || isSubmitted}>Sign in</ButtonLoadingContent>`. |
51
+ | `apps/frontend/components/Account/Auth/Authenticate/ResetPassword.tsx` | `isSubmitting ? 'Resetting password' : 'Reset password'` → `<ButtonLoadingContent>`. |
52
+
53
+ ## API
54
+
55
+ ```ts
56
+ // @clicktap/ui/components/Button
57
+ export function ButtonLoadingContent({
58
+ isLoading?: boolean, // default false
59
+ spinnerStroke?: string, // default '#FFFFFF' (suits solid dark buttons)
60
+ children: ReactNode, // the button's label
61
+ }): JSX.Element;
62
+ ```
63
+
64
+ Renders (semantically):
65
+
66
+ ```tsx
67
+ <span className="inline-flex items-center gap-2">
68
+ <span className="w-5" aria-hidden="true" /> {/* invisible spacer */}
69
+ {children} {/* the label */}
70
+ <span className={isLoading ? 'opacity-100' : 'opacity-0'}>
71
+ <CircularEasing stroke={spinnerStroke} width={20} />
72
+ </span>
73
+ </span>
74
+ ```
75
+
76
+ The spacer matches the spinner's 20px width — the label stays visually centered whether the spinner is visible or not. The spinner fades via opacity, so the layout doesn't shift when `isLoading` flips.
77
+
78
+ ## Usage
79
+
80
+ ```tsx
81
+ import {
82
+ Button,
83
+ ButtonLoadingContent,
84
+ } from '@clicktap/ui/components/Button';
85
+
86
+ <Button
87
+ type="submit"
88
+ isLoading={isSubmitting}
89
+ isDisabled={isSubmitting}
90
+ >
91
+ <ButtonLoadingContent isLoading={isSubmitting}>
92
+ Save Address
93
+ </ButtonLoadingContent>
94
+ </Button>
95
+ ```
96
+
97
+ For outline / ghost buttons (dark text, light background), pass `spinnerStroke`:
98
+
99
+ ```tsx
100
+ <Button variant="outline">
101
+ <ButtonLoadingContent isLoading={isSubmitting} spinnerStroke="#0f172a">
102
+ Cancel
103
+ </ButtonLoadingContent>
104
+ </Button>
105
+ ```
106
+
107
+ ## The NewCardForm `setIsSubmitting(false)` bug
108
+
109
+ A separate but related fix in the same PR: the Cybersource and Auth.Net `NewCardForm` `paymentMethodSubmit` event handlers were doing this:
110
+
111
+ ```ts
112
+ // before
113
+ const handleCustomEvent = (event: Event) => {
114
+ if (detail?.code === paymentMethodCardCode) {
115
+ formRef.current?.dispatchEvent(
116
+ new Event('submit', { cancelable: true, bubbles: true }),
117
+ );
118
+ setIsSubmitting(false); // ← bug: kills the loader before the async work even starts
119
+ }
120
+ };
121
+ ```
122
+
123
+ After dispatching the form submit, the handler flipped `isSubmitting` to false synchronously. The form's `onSubmit` then began the async save-card mutation + step submission, but the Continue-to-review button no longer showed a loader. The loader only reappeared when the checkout state machine reached `submittingStepData` (after the save-card mutation completed).
124
+
125
+ ```ts
126
+ // after
127
+ const handleCustomEvent = (event: Event) => {
128
+ if (detail?.code === paymentMethodCardCode) {
129
+ formRef.current?.dispatchEvent(
130
+ new Event('submit', { cancelable: true, bubbles: true }),
131
+ );
132
+ // Do NOT flip isSubmitting back to false here — the save-card
133
+ // mutation + step submission both run after this dispatch and
134
+ // we want the Continue button's spinner to stay visible the
135
+ // whole time. The error paths in onSubmit and the state
136
+ // machine's submittingStepData transition own the reset-to-false.
137
+ }
138
+ };
139
+ ```
140
+
141
+ Apply this same change to your local `NewCardForm` for both gateways if your project pulled the earlier version.
142
+
143
+ ## Migration Steps
144
+
145
+ 1. **Update `@clicktap/ui`** to the version that exports `ButtonLoadingContent`.
146
+
147
+ 2. **Find your existing inconsistent submit-button patterns.** Quick search commands:
148
+
149
+ ```bash
150
+ # text-swap pattern
151
+ grep -rnE "isSubmitting \\? ['\"]" apps/ libs/ | grep -v vendor | grep -v node_modules
152
+ grep -rnE "isRemoving \\? ['\"]" apps/ libs/ | grep -v vendor | grep -v node_modules
153
+ grep -rnE "isLoading \\? ['\"]" apps/ libs/ | grep -v vendor | grep -v node_modules
154
+
155
+ # spinner-only pattern in Buttons
156
+ grep -rnE "isSubmitting \\? <CircularEasing" apps/ libs/ | grep -v vendor
157
+
158
+ # hand-rolled Place-Order-style pattern
159
+ grep -rn "inline-flex items-center gap-2" apps/ | grep -B1 -A4 "CircularEasing"
160
+ ```
161
+
162
+ 3. **Rewrite each to use `ButtonLoadingContent`.** Three common transforms:
163
+
164
+ ```tsx
165
+ // text-swap → helper
166
+ // before
167
+ <Button isLoading={isSubmitting}>
168
+ {isSubmitting ? 'Updating…' : 'Update'}
169
+ </Button>
170
+ // after
171
+ <Button isLoading={isSubmitting}>
172
+ <ButtonLoadingContent isLoading={isSubmitting}>
173
+ Update
174
+ </ButtonLoadingContent>
175
+ </Button>
176
+ ```
177
+
178
+ ```tsx
179
+ // spinner-only → helper (the label stays visible now)
180
+ // before
181
+ <Button>
182
+ {isSubmitting ? <CircularEasing stroke="#FFFFFF" width={24} /> : nextLabel}
183
+ </Button>
184
+ // after
185
+ <Button>
186
+ <ButtonLoadingContent isLoading={isSubmitting}>
187
+ {nextLabel}
188
+ </ButtonLoadingContent>
189
+ </Button>
190
+ ```
191
+
192
+ ```tsx
193
+ // hand-rolled span pattern → helper (DRY)
194
+ // before
195
+ <Button>
196
+ <span className="inline-flex items-center gap-2">
197
+ <span className="w-5" aria-hidden="true" />
198
+ Place order
199
+ <span className={isSubmitting ? 'opacity-100' : 'opacity-0'}>
200
+ <CircularEasing stroke="#FFFFFF" width={20} />
201
+ </span>
202
+ </span>
203
+ </Button>
204
+ // after
205
+ <Button>
206
+ <ButtonLoadingContent isLoading={isSubmitting}>
207
+ Place order
208
+ </ButtonLoadingContent>
209
+ </Button>
210
+ ```
211
+
212
+ 4. **If your checkout uses the Cybersource or Auth.Net `NewCardForm` pattern**, remove the premature `setIsSubmitting(false)` in the `paymentMethodSubmit` event handler (see "The NewCardForm `setIsSubmitting(false)` bug" above). The save-card mutation + step-submit path own the lifecycle now.
213
+
214
+ 5. **Verify.**
215
+ - Click Place order on the review step: spinner appears on the right of "Place order", label stays put, no layout shift.
216
+ - Click Continue to review on the checkout payment step with an unsaved card: spinner appears immediately and stays visible through both the save-card mutation and the step submission, with no flash of label-only state in between.
217
+ - Click Update / Remove on any saved-card modal: spinner on the right, label stays.
218
+ - Sign in / Sign up / Reset password: same pattern, no "Signing in…" text variants.
219
+
220
+ ## Pattern for Consumers
221
+
222
+ The right-side spinner with persistent label is now the canonical submit-feedback pattern across the whole frontend. When adding a new submit button:
223
+
224
+ ```tsx
225
+ <Button
226
+ type="submit"
227
+ isLoading={isSubmitting}
228
+ isDisabled={isSubmitting}
229
+ >
230
+ <ButtonLoadingContent isLoading={isSubmitting}>
231
+ {/* the static label */}
232
+ </ButtonLoadingContent>
233
+ </Button>
234
+ ```
235
+
236
+ Don't reach for text-swap variants (`'Saving...'`, `'Updating...'`, `'Signing in'`) — they look inconsistent with the rest of the site and force the visitor to re-read the button. Don't reach for spinner-only-when-loading — same problem in reverse.
237
+
238
+ For non-submit buttons that just need a spinner overlay (e.g., a loading state on a non-action element), `CircularEasing` is still exported directly and is the right primitive.
239
+
240
+ ## Files Changed
241
+
242
+ ```
243
+ libs/ui/src/components/Button/ButtonLoadingContent.tsx (new)
244
+ libs/ui/src/components/Button/index.ts
245
+
246
+ apps/frontend/components/Checkout/Step/Payment/Cybersource/NewCardForm.tsx
247
+ apps/frontend/components/Checkout/Step/Payment/Authorizenet/NewCardForm.tsx
248
+ apps/frontend/components/Checkout/Step/StepActions.tsx
249
+ apps/frontend/components/Checkout/Step/Review/CheckoutButton.tsx
250
+ apps/frontend/components/Checkout/Step/Review/CheckoutButton/OfflineCheckoutButton.tsx
251
+ apps/frontend/components/Product/components/AddToCart/AddToCart.tsx
252
+ apps/frontend/components/Checkout/Step/Payment/Cybersource/SavedCardsForm/SavedCard/DeleteButton.tsx
253
+ apps/frontend/components/Checkout/Step/Payment/Cybersource/SavedCardsForm/SavedCard/EditButton/UpdatePayment.tsx
254
+ apps/frontend/components/Checkout/Step/Payment/Authorizenet/SavedCardsForm/SavedCard/DeleteButton.tsx
255
+ apps/frontend/components/Checkout/Step/Payment/Authorizenet/SavedCardsForm/SavedCard/EditButton/UpdatePayment.tsx
256
+ apps/frontend/components/Account/PaymentMethods/Cybersource/PaymentMethod/EditButton/UpdatePayment.tsx
257
+ apps/frontend/components/Account/PaymentMethods/Authorizenet/PaymentMethod/EditButton/UpdatePayment.tsx
258
+ apps/frontend/components/Account/PaymentMethods/Cybersource/CreateNewPayment.tsx
259
+ apps/frontend/components/Account/PaymentMethods/Authorizenet/CreateNewPayment.tsx
260
+ apps/frontend/components/Account/Address/Create.tsx
261
+ apps/frontend/components/Account/Address/Edit.tsx
262
+ apps/frontend/components/Account/Auth/Authenticate/Signup.tsx
263
+ apps/frontend/components/Account/Auth/Authenticate/Signin.tsx
264
+ apps/frontend/components/Account/Auth/Authenticate/ResetPassword.tsx
265
+ ```