@clicktap/ui 0.40.8 → 0.40.10

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 (40) hide show
  1. package/components/Accordion/Accordion.d.ts +1 -1
  2. package/components/Accordion/Accordion.js +1 -1
  3. package/components/Accordion/index.d.ts +2 -2
  4. package/components/Accordion/index.js +1 -1
  5. package/components/Badge/Badge.js +1 -1
  6. package/components/Badge/Badge.types.d.ts +1 -1
  7. package/components/Badge/index.js +1 -1
  8. package/components/Collection/CollectionFilters/CollectionFilters.js +1 -1
  9. package/components/Collection/CollectionFilters/index.js +1 -1
  10. package/components/Collection/index.js +1 -1
  11. package/components/Pagination/Pagination.d.ts +1 -1
  12. package/components/Pagination/Pagination.js +1 -1
  13. package/components/Pagination/index.js +1 -1
  14. package/components/Tooltip/Tooltip.d.ts +1 -1
  15. package/components/Tooltip/Tooltip.js +1 -1
  16. package/components/Tooltip/Tooltip.types.d.ts +1 -1
  17. package/components/Tooltip/index.js +1 -1
  18. package/docs/migrations/2026/01/2026-01-14-external-link-detection.md +290 -0
  19. package/docs/migrations/2026/01/2026-01-14-use-route-hook.md +142 -0
  20. package/docs/migrations/2026/01/2026-01-15-input-disable-skeleton.md +82 -0
  21. package/docs/migrations/2026/02/2026-02-16-env-utils-from-clicktap-ui.md +94 -0
  22. package/docs/migrations/2026/02/2026-02-17-select-popover-portal-container-restored.md +84 -0
  23. package/docs/migrations/2026/03/2026-03-24-image-dimmed-hidden-onready.md +108 -0
  24. package/docs/migrations/2026/03/2026-03-24-modal-overlay-backdrop-dismiss.md +81 -0
  25. package/docs/migrations/2026/04/2026-04-17-select-address-input-onchange-key-or-item.md +182 -0
  26. package/docs/migrations/2026/04/2026-04-24-image-placeholder-frontend-fallback.md +85 -0
  27. package/docs/migrations/2026/04/2026-04-30-prune-invalid-pdp-selections.md +166 -0
  28. package/docs/migrations/2026/04/2026-04-30-top-down-pdp-option-dependency.md +286 -0
  29. package/docs/migrations/2026/05/2026-05-01-build-pdp-url-util.md +141 -0
  30. package/docs/migrations/2026/05/2026-05-03-auth-aware-graphql-client.md +278 -0
  31. package/docs/migrations/2026/05/2026-05-20-framer-motion-11-idioms.md +391 -0
  32. package/docs/migrations/2026/05/2026-05-21-button-loading-content.md +265 -0
  33. package/docs/migrations/2026/05/2026-05-21-password-check-hide-on-empty.md +50 -0
  34. package/docs/migrations/2026/05/2026-05-29-password-check-requirements-variant.md +63 -0
  35. package/docs/migrations/2026/06/2026-06-17-select-standardization.md +286 -0
  36. package/docs/migrations/2026/06/2026-06-18-select-hiddenselect-setvalue-crash.md +75 -0
  37. package/docs/migrations/2026/07/2026-07-31-ui-exports-bundler-resolution.md +50 -0
  38. package/docs/migrations/2026/08/2026-08-02-nextui-to-heroui.md +65 -0
  39. package/docs/migrations/README.md +31 -0
  40. package/package.json +7 -5
@@ -0,0 +1,278 @@
1
+ # Auth-aware GraphQL client — `@clicktap/ui/utils/createAuthAwareGraphqlClient`
2
+
3
+ **Date:** 2026-05-03
4
+ **Affects:** `@clicktap/ui/utils/createAuthAwareGraphqlClient` — new export
5
+ **Classification:** IMPROVEMENT
6
+ **Breaking:** No (additive)
7
+
8
+ ## Dependencies
9
+
10
+ *None.*
11
+
12
+ ## Summary
13
+
14
+ Adds two factories and an event bus for handling `graphql-request` responses uniformly across consumer apps:
15
+
16
+ - `createAuthAwareGraphqlClient()` — returns a `request(url, doc, vars, headers)` callable plus a `.withOptions({ … signal })` overload. Drop-in for any caller that previously used bare `request()` from `graphql-request`.
17
+ - `createAuthAwareFetch()` — returns a `fetch` function suitable for `new GraphQLClient(url, { fetch: … })` when the caller manages their own client config (cors, credentials, custom headers).
18
+ - `authAwareEvents` — a small, app-runtime-agnostic event bus. Both factories dispatch `unauthenticated` (HTTP 401), `permissionDenied` (403), and `serverError` (5xx) events. Consumer apps subscribe from a Provider component (where xstate actors and routers are in scope) to wire logout / toast / redirect.
19
+
20
+ The `request()` form additionally returns the partial `data` from a HTTP-200-with-errors response — single-field GraphQL errors don't tear down the whole UI. (Same behavior the prior project-level `apps/frontend/utils/graphqlRequest.ts` had — moved here so every consumer gets it for free.)
21
+
22
+ ## Why
23
+
24
+ Every consumer app was reinventing the same four pieces in its own actors / mutation handlers:
25
+
26
+ 1. 401 → log out + toast + redirect to login
27
+ 2. 403 → permission-denied toast
28
+ 3. 5xx → service-unavailable toast
29
+ 4. Partial responses → return `data`, don't throw
30
+
31
+ The 401 path in particular was load-bearing for the upcoming "no-guest storefront" pattern (see [`2026-05-03-guest-checkout-disabled-storefront.md`](#) cross-app guides), but its scope is broader: any project that wants its session-expired UX coherent benefits.
32
+
33
+ The factory pattern decouples the lib from app runtime context. The lib emits typed events; the app subscribes from a Provider where it has the runtime concerns (auth machine, toast actor, router). Lib stays pure; consumers stay flexible.
34
+
35
+ ## Public API
36
+
37
+ ```ts
38
+ import {
39
+ createAuthAwareGraphqlClient,
40
+ createAuthAwareFetch,
41
+ authAwareEvents,
42
+ ClientError, // re-exported — see "Cross-package error handling" below
43
+ GraphQLClient, // re-exported
44
+ type AuthAwareEventName,
45
+ type AuthAwareEventDetail,
46
+ } from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
47
+
48
+ import { getRequestErrorStatus } from '@clicktap/ui/utils/error';
49
+ ```
50
+
51
+ ```ts
52
+ /**
53
+ * Returns a callable: `(url, document, variables?, headers?) => Promise<TData>`.
54
+ * Plus a `.withOptions({ url, document, variables?, headers?, signal? })` form
55
+ * for actors needing AbortSignal support.
56
+ *
57
+ * Internally creates a fresh GraphQLClient per call. On error: dispatches
58
+ * the appropriate event (401/403/5xx) and either rethrows or returns the
59
+ * partial `data` from a HTTP-200-with-errors response.
60
+ */
61
+ function createAuthAwareGraphqlClient(): AuthAwareGraphqlClient;
62
+
63
+ /**
64
+ * Returns a `fetch` function. Pass to:
65
+ * new GraphQLClient(url, { fetch: createAuthAwareFetch(), credentials, mode, … })
66
+ *
67
+ * Wraps the global `fetch`. On 401/403/5xx response, dispatches the
68
+ * matching event with `{ status, response }`. The original Response is
69
+ * returned untouched — graphql-request still parses, throws, etc. exactly
70
+ * as it would have. The event dispatch is a side channel.
71
+ */
72
+ function createAuthAwareFetch(): typeof fetch;
73
+
74
+ /**
75
+ * Module-level event bus. Subscribe with a callback; returns an unsubscribe
76
+ * function.
77
+ *
78
+ * authAwareEvents.subscribe('unauthenticated', ({ status, error, response }) => { … });
79
+ */
80
+ export const authAwareEvents: {
81
+ subscribe(name: AuthAwareEventName, listener: (detail: AuthAwareEventDetail) => void): () => void;
82
+ dispatch(name: AuthAwareEventName, detail: AuthAwareEventDetail): void;
83
+ };
84
+
85
+ type AuthAwareEventName = 'unauthenticated' | 'permissionDenied' | 'serverError';
86
+ interface AuthAwareEventDetail {
87
+ status: number;
88
+ /** Set when triggered from a `graphql-request` ClientError. */
89
+ error?: ClientError;
90
+ /** Set when triggered from the fetch wrapper. */
91
+ response?: Response;
92
+ }
93
+ ```
94
+
95
+ ## Pattern for consumers
96
+
97
+ Inside a Provider component (where actors + router + toast are in scope):
98
+
99
+ ```tsx
100
+ import { authAwareEvents } from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
101
+ import { useAuth } from '@/state/auth/AuthProvider';
102
+ import { useToast } from '@/state/toast/ToastProvider';
103
+
104
+ function AuthAwareEventBridge({ children }: { children: ReactNode }) {
105
+ const { authActor } = useAuth();
106
+ const { toastActor } = useToast();
107
+
108
+ useEffect(() => {
109
+ const unsubUnauth = authAwareEvents.subscribe('unauthenticated', () => {
110
+ authActor.send({ type: 'LOGOUT' });
111
+ toastActor.send({
112
+ type: 'ADD_ITEM',
113
+ item: <Alert type="error" title="Session expired" message="Please log in again." />,
114
+ duration: 5000,
115
+ });
116
+ });
117
+ const unsubPerm = authAwareEvents.subscribe('permissionDenied', () => {
118
+ toastActor.send({
119
+ type: 'ADD_ITEM',
120
+ item: <Alert type="error" title="Permission denied" />,
121
+ duration: 5000,
122
+ });
123
+ });
124
+ const unsub5xx = authAwareEvents.subscribe('serverError', () => {
125
+ toastActor.send({
126
+ type: 'ADD_ITEM',
127
+ item: <Alert type="error" title="Service unavailable" />,
128
+ duration: 5000,
129
+ });
130
+ });
131
+ return () => { unsubUnauth(); unsubPerm(); unsub5xx(); };
132
+ }, [authActor, toastActor]);
133
+
134
+ return <>{children}</>;
135
+ }
136
+ ```
137
+
138
+ For raw callers, two flavors:
139
+
140
+ **Caller that previously used `request()` from `graphql-request`:**
141
+
142
+ ```ts
143
+ // Before
144
+ import { request } from 'graphql-request';
145
+ const data = await request(url, document, vars, headers);
146
+
147
+ // After
148
+ import { graphqlRequest } from '@/utils/graphqlRequest';
149
+ // where graphqlRequest is `createAuthAwareGraphqlClient()` instantiated once at module scope
150
+ const data = await graphqlRequest(url, document, vars, headers);
151
+ ```
152
+
153
+ **Caller that constructs its own `GraphQLClient`:**
154
+
155
+ ```ts
156
+ // Before
157
+ const client = new GraphQLClient(url, { credentials: 'include', mode: 'cors' });
158
+
159
+ // After
160
+ import { createAuthAwareFetch } from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
161
+ const client = new GraphQLClient(url, {
162
+ credentials: 'include',
163
+ mode: 'cors',
164
+ fetch: createAuthAwareFetch(),
165
+ });
166
+ ```
167
+
168
+ ## Cross-package error handling — `ClientError` re-export + `getRequestErrorStatus`
169
+
170
+ **Added 2026-06-15.**
171
+
172
+ `graphql-request` is bundled into every consuming package, so its `ClientError`
173
+ class is a *different object* in each bundle. A consumer that catches an error
174
+ thrown by this client and checks `error instanceof ClientError` using its **own**
175
+ `import { ClientError } from 'graphql-request'` always gets `false` — the class
176
+ identities don't match across the package boundary. In practice this silently
177
+ broke the 401→login redirect on SSR route fetches: the `instanceof` guard
178
+ failed, the 401 fell through to a rethrow, and the page **500'd instead of
179
+ redirecting**.
180
+
181
+ Two fixes, both additive:
182
+
183
+ 1. **`ClientError` (and `GraphQLClient`) are re-exported from the client
184
+ module.** Import them from `@clicktap/ui` so the class identity matches the
185
+ one this client throws:
186
+
187
+ ```ts
188
+ import { ClientError } from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
189
+
190
+ try {
191
+ return await fetchRoute(path);
192
+ } catch (error) {
193
+ if (error instanceof ClientError && error.response?.status === 401) {
194
+ redirect('/account/authenticate?…');
195
+ }
196
+ throw error;
197
+ }
198
+ ```
199
+
200
+ 2. **`getRequestErrorStatus(error)` — identity-agnostic status extraction.**
201
+ Prefer this at cross-package boundaries: it duck-types `response.status`, so
202
+ it works regardless of which bundle threw, with no `instanceof` at all.
203
+ Returns `undefined` when no numeric status is present.
204
+
205
+ ```ts
206
+ import { getRequestErrorStatus } from '@clicktap/ui/utils/error';
207
+
208
+ try {
209
+ return await fetchRoute(path);
210
+ } catch (error) {
211
+ if (getRequestErrorStatus(error) === 401) {
212
+ redirect(`/account/authenticate?redirect=${path}&reason=authentication_required`);
213
+ }
214
+ throw error;
215
+ }
216
+ ```
217
+
218
+ ### SSR route fetches must forward the auth token
219
+
220
+ Now that catalog/entity routes can be auth-gated on the backend (the router's
221
+ `EntityAuthenticationGate` 401s a guest request whose route resolves to a gated
222
+ entity type), an SSR route/data fetch that omits the caller's bearer token makes
223
+ a **logged-in user look unauthenticated to the gate** — they get bounced like a
224
+ guest. SSR fetches for gated routes must forward `Authorization: Bearer <token>`
225
+ from the request's session:
226
+
227
+ ```ts
228
+ const token = await getAccessToken(); // request-memoized; no extra round-trip
229
+ const data = await graphqlRequest(url, GetRoute, { path }, {
230
+ ...scopeHeaders,
231
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
232
+ });
233
+ ```
234
+
235
+ The storefront-auth-gating guide documents where route protection is configured.
236
+
237
+ ## Migration Steps
238
+
239
+ This is purely additive — existing code keeps compiling. To adopt:
240
+
241
+ ### 1. Bump `@clicktap/ui`
242
+
243
+ ```bash
244
+ pnpm up @clicktap/ui
245
+ ```
246
+
247
+ Minimum version: **next minor after the release that ships this migration**.
248
+
249
+ ### 2. Build a Provider-level event bridge
250
+
251
+ Mount it once high in the tree (alongside `AuthProvider` / `ToastProvider`). Subscribe to the three events, dispatch into your project's runtime. See "Pattern for consumers" above.
252
+
253
+ ### 3. (Incremental) Migrate actors that hit gated GraphQL operations
254
+
255
+ For any actor that performs a mutation the backend has gated behind auth (e.g. cart mutations on a no-guest storefront), switch its GraphQL call to one of the two forms above. The wrapping is what fires the events.
256
+
257
+ Actors that hit non-gated operations don't need migration unless you want their 5xx / 403 to surface a toast too — adopt incrementally.
258
+
259
+ ## Verification
260
+
261
+ - **Typecheck** clean.
262
+ - **Browser smoke test**:
263
+ 1. Mount a page that performs a wrapped GraphQL request.
264
+ 2. Force the response to 401 (devtools → request blocking → respond with 401, or expire the JWT).
265
+ 3. Confirm: subscriber callback fires; auth/toast/router behave as wired.
266
+ 4. Repeat for 403 and 503.
267
+
268
+ ## Files Changed
269
+
270
+ | File | Change |
271
+ |---|---|
272
+ | `libs/ui/src/utils/createAuthAwareGraphqlClient.ts` | NEW — factories + event bus; re-exports `ClientError`/`GraphQLClient` (2026-06-15) |
273
+ | `libs/ui/src/utils/error.ts` | `getRequestErrorStatus()` — identity-agnostic HTTP-status extraction (2026-06-15) |
274
+
275
+ ## References
276
+
277
+ - Frontend wiring (consumes this): [`apps/frontend/.../2026-05-03-guest-checkout-disabled-storefront.md`](../../../../../apps/frontend/docs/migrations/2026/05/2026-05-03-guest-checkout-disabled-storefront.md)
278
+ - Backend that 401s the gated operations: [`apps/middleware/.../2026-05-03-guest-checkout-disabled-storefront.md`](../../../../../apps/middleware/docs/migrations/2026/05/2026-05-03-guest-checkout-disabled-storefront.md)
@@ -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.