@luxfi/ui 7.4.6 → 7.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/skeleton.js CHANGED
@@ -122,11 +122,7 @@ function stripShims(props) {
122
122
  for (const k of SHIM_PROPS) delete out[k];
123
123
  return out;
124
124
  }
125
- var SKELETON_CLASSES = [
126
- "animate-skeleton-shimmer rounded-sm",
127
- "bg-[linear-gradient(90deg,var(--color-skeleton-start)_0%,var(--color-skeleton-end)_50%,var(--color-skeleton-start)_100%)]",
128
- "bg-[length:200%_100%]"
129
- ].join(" ");
125
+ var SKELETON_CLASSES = "lux-skeleton rounded-sm";
130
126
  var HIDE_BELOW_MAP = { lg: "lg:hidden", md: "md:hidden", sm: "sm:hidden" };
131
127
  var Skeleton = React.forwardRef(
132
128
  function Skeleton2(props, ref) {
@@ -146,7 +142,7 @@ var Skeleton = React.forwardRef(
146
142
  {
147
143
  ref,
148
144
  "data-loading": true,
149
- className: cn(SKELETON_CLASSES, "text-transparent [&_*]:invisible", cls),
145
+ className: cn(SKELETON_CLASSES, "[&_*]:invisible", cls),
150
146
  style: mergedStyle,
151
147
  ...htmlRest,
152
148
  children
@@ -158,7 +154,7 @@ var Skeleton = React.forwardRef(
158
154
  {
159
155
  ref,
160
156
  "data-loading": true,
161
- className: cn(SKELETON_CLASSES, children ? "text-transparent [&_*]:invisible" : "min-h-5", cls),
157
+ className: cn(SKELETON_CLASSES, children ? "[&_*]:invisible" : "min-h-5", cls),
162
158
  style: mergedStyle,
163
159
  ...htmlRest,
164
160
  children
@@ -195,5 +191,9 @@ var SkeletonText = React.forwardRef(
195
191
  )) });
196
192
  }
197
193
  );
194
+ function LoadingBar({ loading = true }) {
195
+ if (!loading) return null;
196
+ return /* @__PURE__ */ jsx("div", { className: "lux-loading", role: "progressbar", "aria-busy": "true", "aria-label": "Loading" });
197
+ }
198
198
 
199
- export { Skeleton, SkeletonCircle, SkeletonText };
199
+ export { LoadingBar, Skeleton, SkeletonCircle, SkeletonText };
package/dist/table.cjs CHANGED
@@ -145,11 +145,7 @@ function stripShims(props) {
145
145
  for (const k of SHIM_PROPS) delete out[k];
146
146
  return out;
147
147
  }
148
- var SKELETON_CLASSES = [
149
- "animate-skeleton-shimmer rounded-sm",
150
- "bg-[linear-gradient(90deg,var(--color-skeleton-start)_0%,var(--color-skeleton-end)_50%,var(--color-skeleton-start)_100%)]",
151
- "bg-[length:200%_100%]"
152
- ].join(" ");
148
+ var SKELETON_CLASSES = "lux-skeleton rounded-sm";
153
149
  var HIDE_BELOW_MAP = { lg: "lg:hidden", md: "md:hidden", sm: "sm:hidden" };
154
150
  var Skeleton = React3__namespace.forwardRef(
155
151
  function Skeleton2(props, ref) {
@@ -169,7 +165,7 @@ var Skeleton = React3__namespace.forwardRef(
169
165
  {
170
166
  ref,
171
167
  "data-loading": true,
172
- className: cn(SKELETON_CLASSES, "text-transparent [&_*]:invisible", cls),
168
+ className: cn(SKELETON_CLASSES, "[&_*]:invisible", cls),
173
169
  style: mergedStyle,
174
170
  ...htmlRest,
175
171
  children
@@ -181,7 +177,7 @@ var Skeleton = React3__namespace.forwardRef(
181
177
  {
182
178
  ref,
183
179
  "data-loading": true,
184
- className: cn(SKELETON_CLASSES, children ? "text-transparent [&_*]:invisible" : "min-h-5", cls),
180
+ className: cn(SKELETON_CLASSES, children ? "[&_*]:invisible" : "min-h-5", cls),
185
181
  style: mergedStyle,
186
182
  ...htmlRest,
187
183
  children
package/dist/table.js CHANGED
@@ -124,11 +124,7 @@ function stripShims(props) {
124
124
  for (const k of SHIM_PROPS) delete out[k];
125
125
  return out;
126
126
  }
127
- var SKELETON_CLASSES = [
128
- "animate-skeleton-shimmer rounded-sm",
129
- "bg-[linear-gradient(90deg,var(--color-skeleton-start)_0%,var(--color-skeleton-end)_50%,var(--color-skeleton-start)_100%)]",
130
- "bg-[length:200%_100%]"
131
- ].join(" ");
127
+ var SKELETON_CLASSES = "lux-skeleton rounded-sm";
132
128
  var HIDE_BELOW_MAP = { lg: "lg:hidden", md: "md:hidden", sm: "sm:hidden" };
133
129
  var Skeleton = React3.forwardRef(
134
130
  function Skeleton2(props, ref) {
@@ -148,7 +144,7 @@ var Skeleton = React3.forwardRef(
148
144
  {
149
145
  ref,
150
146
  "data-loading": true,
151
- className: cn(SKELETON_CLASSES, "text-transparent [&_*]:invisible", cls),
147
+ className: cn(SKELETON_CLASSES, "[&_*]:invisible", cls),
152
148
  style: mergedStyle,
153
149
  ...htmlRest,
154
150
  children
@@ -160,7 +156,7 @@ var Skeleton = React3.forwardRef(
160
156
  {
161
157
  ref,
162
158
  "data-loading": true,
163
- className: cn(SKELETON_CLASSES, children ? "text-transparent [&_*]:invisible" : "min-h-5", cls),
159
+ className: cn(SKELETON_CLASSES, children ? "[&_*]:invisible" : "min-h-5", cls),
164
160
  style: mergedStyle,
165
161
  ...htmlRest,
166
162
  children
package/dist/tag.cjs CHANGED
@@ -208,11 +208,7 @@ function stripShims(props) {
208
208
  for (const k of SHIM_PROPS) delete out[k];
209
209
  return out;
210
210
  }
211
- var SKELETON_CLASSES = [
212
- "animate-skeleton-shimmer rounded-sm",
213
- "bg-[linear-gradient(90deg,var(--color-skeleton-start)_0%,var(--color-skeleton-end)_50%,var(--color-skeleton-start)_100%)]",
214
- "bg-[length:200%_100%]"
215
- ].join(" ");
211
+ var SKELETON_CLASSES = "lux-skeleton rounded-sm";
216
212
  var HIDE_BELOW_MAP = { lg: "lg:hidden", md: "md:hidden", sm: "sm:hidden" };
217
213
  var Skeleton = React4__namespace.forwardRef(
218
214
  function Skeleton2(props, ref) {
@@ -232,7 +228,7 @@ var Skeleton = React4__namespace.forwardRef(
232
228
  {
233
229
  ref,
234
230
  "data-loading": true,
235
- className: cn(SKELETON_CLASSES, "text-transparent [&_*]:invisible", cls),
231
+ className: cn(SKELETON_CLASSES, "[&_*]:invisible", cls),
236
232
  style: mergedStyle,
237
233
  ...htmlRest,
238
234
  children
@@ -244,7 +240,7 @@ var Skeleton = React4__namespace.forwardRef(
244
240
  {
245
241
  ref,
246
242
  "data-loading": true,
247
- className: cn(SKELETON_CLASSES, children ? "text-transparent [&_*]:invisible" : "min-h-5", cls),
243
+ className: cn(SKELETON_CLASSES, children ? "[&_*]:invisible" : "min-h-5", cls),
248
244
  style: mergedStyle,
249
245
  ...htmlRest,
250
246
  children
@@ -375,7 +371,7 @@ var Tooltip = React4__namespace.forwardRef(
375
371
  {
376
372
  ref: triggerRef,
377
373
  asChild: true,
378
- ...isMobile ? { onPress: handleTriggerClick } : {},
374
+ ...isMobile ? { onClick: handleTriggerClick } : {},
379
375
  ...triggerProps,
380
376
  children
381
377
  }
package/dist/tag.js CHANGED
@@ -186,11 +186,7 @@ function stripShims(props) {
186
186
  for (const k of SHIM_PROPS) delete out[k];
187
187
  return out;
188
188
  }
189
- var SKELETON_CLASSES = [
190
- "animate-skeleton-shimmer rounded-sm",
191
- "bg-[linear-gradient(90deg,var(--color-skeleton-start)_0%,var(--color-skeleton-end)_50%,var(--color-skeleton-start)_100%)]",
192
- "bg-[length:200%_100%]"
193
- ].join(" ");
189
+ var SKELETON_CLASSES = "lux-skeleton rounded-sm";
194
190
  var HIDE_BELOW_MAP = { lg: "lg:hidden", md: "md:hidden", sm: "sm:hidden" };
195
191
  var Skeleton = React4.forwardRef(
196
192
  function Skeleton2(props, ref) {
@@ -210,7 +206,7 @@ var Skeleton = React4.forwardRef(
210
206
  {
211
207
  ref,
212
208
  "data-loading": true,
213
- className: cn(SKELETON_CLASSES, "text-transparent [&_*]:invisible", cls),
209
+ className: cn(SKELETON_CLASSES, "[&_*]:invisible", cls),
214
210
  style: mergedStyle,
215
211
  ...htmlRest,
216
212
  children
@@ -222,7 +218,7 @@ var Skeleton = React4.forwardRef(
222
218
  {
223
219
  ref,
224
220
  "data-loading": true,
225
- className: cn(SKELETON_CLASSES, children ? "text-transparent [&_*]:invisible" : "min-h-5", cls),
221
+ className: cn(SKELETON_CLASSES, children ? "[&_*]:invisible" : "min-h-5", cls),
226
222
  style: mergedStyle,
227
223
  ...htmlRest,
228
224
  children
@@ -353,7 +349,7 @@ var Tooltip = React4.forwardRef(
353
349
  {
354
350
  ref: triggerRef,
355
351
  asChild: true,
356
- ...isMobile ? { onPress: handleTriggerClick } : {},
352
+ ...isMobile ? { onClick: handleTriggerClick } : {},
357
353
  ...triggerProps,
358
354
  children
359
355
  }
package/dist/tooltip.cjs CHANGED
@@ -126,7 +126,7 @@ var Tooltip = React2__namespace.forwardRef(
126
126
  {
127
127
  ref: triggerRef,
128
128
  asChild: true,
129
- ...isMobile ? { onPress: handleTriggerClick } : {},
129
+ ...isMobile ? { onClick: handleTriggerClick } : {},
130
130
  ...triggerProps,
131
131
  children
132
132
  }
package/dist/tooltip.js CHANGED
@@ -104,7 +104,7 @@ var Tooltip = React2.forwardRef(
104
104
  {
105
105
  ref: triggerRef,
106
106
  asChild: true,
107
- ...isMobile ? { onPress: handleTriggerClick } : {},
107
+ ...isMobile ? { onClick: handleTriggerClick } : {},
108
108
  ...triggerProps,
109
109
  children
110
110
  }
package/dist/vite.cjs CHANGED
@@ -73,14 +73,15 @@ var GUI_DEFINES = {
73
73
  "process.env.EXPO_OS": JSON.stringify("web"),
74
74
  "process.env.IS_WEB": JSON.stringify("true")
75
75
  };
76
-
77
- // src/vite.ts
78
- var DEDUPE = [
76
+ var ENGINE_DEDUPE = [
79
77
  "react",
80
78
  "react-dom",
81
79
  "@tanstack/react-query",
82
80
  ...GUI_PACKAGES
83
81
  ];
82
+
83
+ // src/vite.ts
84
+ var DEDUPE = ENGINE_DEDUPE;
84
85
  var INCLUDE = [
85
86
  "react-native-web",
86
87
  "@react-native/normalize-color",
package/dist/vite.js CHANGED
@@ -71,14 +71,15 @@ var GUI_DEFINES = {
71
71
  "process.env.EXPO_OS": JSON.stringify("web"),
72
72
  "process.env.IS_WEB": JSON.stringify("true")
73
73
  };
74
-
75
- // src/vite.ts
76
- var DEDUPE = [
74
+ var ENGINE_DEDUPE = [
77
75
  "react",
78
76
  "react-dom",
79
77
  "@tanstack/react-query",
80
78
  ...GUI_PACKAGES
81
79
  ];
80
+
81
+ // src/vite.ts
82
+ var DEDUPE = ENGINE_DEDUPE;
82
83
  var INCLUDE = [
83
84
  "react-native-web",
84
85
  "@react-native/normalize-color",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luxfi/ui",
3
- "version": "7.4.6",
3
+ "version": "7.4.7",
4
4
  "description": "Cross-platform DeFi UI components built on @hanzo/gui (Tamagui). Native mobile + web.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,6 +50,11 @@
50
50
  "import": "./dist/iam.js",
51
51
  "require": "./dist/iam.cjs"
52
52
  },
53
+ "./auth": {
54
+ "types": "./dist/auth.d.ts",
55
+ "import": "./dist/auth.js",
56
+ "require": "./dist/auth.cjs"
57
+ },
53
58
  "./identity": {
54
59
  "types": "./dist/identity.d.ts",
55
60
  "import": "./dist/identity.js",
@@ -321,14 +326,15 @@
321
326
  "tailwind-merge": "^3.0.0"
322
327
  },
323
328
  "peerDependencies": {
324
- "@tanstack/react-query": "^5.76.0",
325
329
  "@hanzo/gui": "^7.3.0",
330
+ "@hanzo/iam": "^0.13.8",
326
331
  "@hanzogui/colors": "^7.3.0",
327
332
  "@hanzogui/config": "^7.3.0",
328
333
  "@hanzogui/core": "^7.3.0",
329
334
  "@hanzogui/lucide-icons-2": "^7.3.0",
330
335
  "@hanzogui/themes": "^7.3.0",
331
336
  "@hanzogui/web": "^7.3.0",
337
+ "@tanstack/react-query": "^5.76.0",
332
338
  "react": ">=19",
333
339
  "react-dom": ">=19"
334
340
  },
@@ -347,10 +353,14 @@
347
353
  },
348
354
  "@hanzogui/web": {
349
355
  "optional": true
356
+ },
357
+ "@hanzo/iam": {
358
+ "optional": true
350
359
  }
351
360
  },
352
361
  "devDependencies": {
353
362
  "@hanzo/gui": "7.3.0",
363
+ "@hanzo/iam": "^0.13.8",
354
364
  "@hanzogui/colors": "7.3.0",
355
365
  "@hanzogui/config": "7.3.0",
356
366
  "@hanzogui/core": "7.3.0",
package/src/auth.tsx ADDED
@@ -0,0 +1,198 @@
1
+ 'use client';
2
+
3
+ import { configureIam, handleCallback, logout, startLogin } from '@hanzo/iam/browser';
4
+ import * as React from 'react';
5
+
6
+ import { IdentityProvider, type IdentityAuth } from './identity';
7
+ import { useWhiteLabel } from './provider';
8
+ import type { WhiteLabel } from './white-label';
9
+
10
+ // The sign-in half of identity, in ONE place.
11
+ //
12
+ // ./identity answers "who is signed in" and ./chrome renders it (the account
13
+ // switcher already shows `Sign in with <iamDomain>` when anonymous and
14
+ // `Sign out` when ready). Neither of them can START a session — `IdentityAuth`
15
+ // is a seam, and every surface filled it in by hand. That is where the fleet
16
+ // diverged:
17
+ //
18
+ // lux/explore/lib/oidc.ts hand-built authorize URL, NO PKCE at all
19
+ // lux/mpc/dashboard/app/providers configureIam + startLogin + logout (right)
20
+ // lux/market/src/app/providers the same three lines again
21
+ // lux/cloud/apps/web/src/iam.ts a third shape: IAMConfig from a brand table
22
+ // plus VITE_IAM_URL / VITE_IAM_CLIENT_ID
23
+ // env overrides — i.e. brand per DEPLOYMENT
24
+ //
25
+ // Four copies, and only one of them was correct. So the flow lives here:
26
+ //
27
+ // <AppProvider> host → brand, theme, issuer, clientId
28
+ // <AuthProvider> the three verbs, wired to that host
29
+ // …and on the callback route:
30
+ // <AuthCallback/>
31
+ //
32
+ // MECHANISM IS NOT OURS. Authorization-Code + PKCE-S256, the state nonce, the
33
+ // verifier, discovery and the token store all belong to `@hanzo/iam/browser`;
34
+ // this module only decides WHICH issuer and WHICH client — and that is a pure
35
+ // function of the Host header (./white-label). A design system that reimplements
36
+ // PKCE is a design system with its own CVEs.
37
+
38
+ /**
39
+ * The ONE callback route. Registered as `https://<host>/auth/callback` for
40
+ * every browser client in the fleet — never under `/api/`, never per app.
41
+ */
42
+ export const CALLBACK_PATH = '/auth/callback';
43
+
44
+ /** What `handleCallback` hands back. Structural, so the SDK's types stay its own. */
45
+ export interface AuthResult {
46
+ readonly token: { readonly accessToken: string };
47
+ readonly redirect: string;
48
+ }
49
+
50
+ /**
51
+ * Point `@hanzo/iam` at the issuer and client this HOST resolves to.
52
+ *
53
+ * Idempotent per config, browser-only, and safe to call from anywhere — which
54
+ * is the point: no ordering rule between a provider's effect and a child's.
55
+ */
56
+ export function configureAuth(wl: WhiteLabel): void {
57
+ if (typeof window === 'undefined') return;
58
+ configureIam({
59
+ issuer: wl.issuer,
60
+ clientId: wl.clientId,
61
+ // The SDK already defaults to `${origin}${CALLBACK_PATH}`; naming it here
62
+ // makes the fleet-wide invariant one readable, testable line instead of a
63
+ // default nobody can see.
64
+ redirect: `${ window.location.origin }${ CALLBACK_PATH }`,
65
+ });
66
+ }
67
+
68
+ export interface Auth {
69
+ /** Start the PKCE login. `redirect` is where to land afterwards. */
70
+ readonly signIn: (redirect?: string) => void;
71
+ /** RP-initiated logout + local token clear. */
72
+ readonly signOut: () => void;
73
+ /** Finish the login on the callback route. */
74
+ readonly complete: () => Promise<AuthResult>;
75
+ /** The host-resolved brand, issuer and client these verbs are bound to. */
76
+ readonly whiteLabel: WhiteLabel;
77
+ }
78
+
79
+ /**
80
+ * The three auth verbs, bound to the current host.
81
+ *
82
+ * Configuration happens during render, before any caller can use a verb, so a
83
+ * callback route works whether or not it sits under an `<AuthProvider>`.
84
+ */
85
+ export function useAuth(defaultRedirect = '/'): Auth {
86
+ const whiteLabel = useWhiteLabel();
87
+ configureAuth(whiteLabel);
88
+ return React.useMemo<Auth>(() => ({
89
+ signIn: (redirect = defaultRedirect) => void startLogin({ redirect }),
90
+ signOut: () => void logout(),
91
+ complete: () => handleCallback() as Promise<AuthResult>,
92
+ whiteLabel,
93
+ }), [ whiteLabel, defaultRedirect ]);
94
+ }
95
+
96
+ export interface AuthProviderProps {
97
+ readonly children: React.ReactNode;
98
+ /** Where a successful sign-in lands. Default `/`. */
99
+ readonly redirect?: string;
100
+ /**
101
+ * Anything the surface must tear down alongside the IAM session — the mirror
102
+ * of {@link AuthCallbackProps.onSignedIn}. A surface that mints its own
103
+ * downstream credential from the IAM token drops it here; the IAM session
104
+ * itself is never the app's to manage.
105
+ */
106
+ readonly onSignOut?: () => void;
107
+ }
108
+
109
+ /**
110
+ * `IdentityProvider` with the credential half filled in.
111
+ *
112
+ * A surface's root becomes `<AppProvider><AuthProvider>…` and nothing else —
113
+ * no `configureIam`, no `startLogin`, no per-app token keys.
114
+ */
115
+ export const AuthProvider = ({ children, redirect = '/', onSignOut }: AuthProviderProps): React.ReactElement => {
116
+ const auth = useAuth(redirect);
117
+ const identityAuth = React.useMemo<IdentityAuth>(() => ({
118
+ signIn: () => auth.signIn(redirect),
119
+ signOut: () => {
120
+ onSignOut?.();
121
+ auth.signOut();
122
+ },
123
+ }), [ auth, redirect, onSignOut ]);
124
+ return <IdentityProvider auth={ identityAuth }>{ children }</IdentityProvider>;
125
+ };
126
+
127
+ export interface AuthCallbackProps {
128
+ /**
129
+ * Anything the surface must do with the fresh session before it navigates —
130
+ * e.g. the MPC dashboard mints its own API JWT from the IAM token. Throwing
131
+ * here does NOT fail the sign-in: the user is signed in, and a downstream
132
+ * service being unreachable is that service's problem to report.
133
+ */
134
+ readonly onSignedIn?: (result: AuthResult) => void | Promise<void>;
135
+ /** Router hop. Defaults to a full replace, which needs no router. */
136
+ readonly navigate?: (to: string) => void;
137
+ /** Where to go when the callback carried no stashed redirect. */
138
+ readonly fallback?: string;
139
+ /** Sign-in entry point offered after a failure. */
140
+ readonly retryPath?: string;
141
+ }
142
+
143
+ /**
144
+ * The ONE callback page body: verify state, exchange the code, go where the
145
+ * user was headed. Mount it at {@link CALLBACK_PATH}.
146
+ */
147
+ export const AuthCallback = ({
148
+ onSignedIn,
149
+ navigate,
150
+ fallback = '/',
151
+ retryPath = '/login',
152
+ }: AuthCallbackProps): React.ReactElement => {
153
+ const auth = useAuth();
154
+ const [ error, setError ] = React.useState<string | null>(null);
155
+
156
+ React.useEffect(() => {
157
+ let live = true;
158
+ void (async () => {
159
+ try {
160
+ const result = await auth.complete();
161
+ if (!live) return;
162
+ try {
163
+ await onSignedIn?.(result);
164
+ } catch {
165
+ // Signed in; the surface's own downstream call failed. Not a login
166
+ // failure, and stranding a good session on this screen was the bug.
167
+ }
168
+ if (!live) return;
169
+ const to = result.redirect || fallback;
170
+ if (navigate) navigate(to);
171
+ else window.location.replace(to);
172
+ } catch (e) {
173
+ if (live) setError(e instanceof Error ? e.message : 'Authentication failed');
174
+ }
175
+ })();
176
+ return () => {
177
+ live = false;
178
+ };
179
+ // `auth` is re-created only when the host changes; the exchange must run once.
180
+ // eslint-disable-next-line react-hooks/exhaustive-deps
181
+ }, []);
182
+
183
+ return (
184
+ <div className="flex min-h-[50vh] items-center justify-center px-4">
185
+ <div className="w-full max-w-sm text-center">
186
+ { error ? (
187
+ <>
188
+ <p className="mb-2 text-lg font-semibold text-[var(--color-text-error)]">Sign-in failed</p>
189
+ <p className="mb-6 text-sm text-[var(--color-text-secondary)]">{ error }</p>
190
+ <a href={ retryPath } className="text-sm underline underline-offset-4">Try again</a>
191
+ </>
192
+ ) : (
193
+ <p className="text-sm text-[var(--color-text-secondary)]">Completing sign in…</p>
194
+ ) }
195
+ </div>
196
+ </div>
197
+ );
198
+ };
package/src/engine.ts CHANGED
@@ -105,8 +105,29 @@ export const GUI_DEFINES: Readonly<Record<string, string>> = {
105
105
  };
106
106
 
107
107
  /**
108
- * Exact-match aliases pinning every engine package to ONE entry file, for
109
- * bundlers that have no `dedupe` of their own (webpack).
108
+ * Every package that must exist exactly ONCE in a bundle — the engine, plus the
109
+ * three libraries outside it that also publish a module-scope React context.
110
+ *
111
+ * `react` and `react-dom` are the classic case. `@tanstack/react-query` is the
112
+ * same failure with a different message: lux.market resolved two copies (one
113
+ * under react@19.1.0, one under react@19.2.8), the app's `QueryClientProvider`
114
+ * came from copy A and `@luxfi/ui`'s `useQuery` read copy B's context, and the
115
+ * whole page died with "No QueryClient set, use QueryClientProvider to set one".
116
+ *
117
+ * ONE list, shared by both bundler wrappers, so vite and next cannot dedupe
118
+ * different sets — which is exactly how that market bug survived: the vite
119
+ * wrapper already listed these three, the Next wrapper did not.
120
+ */
121
+ export const ENGINE_DEDUPE: ReadonlyArray<string> = [
122
+ 'react',
123
+ 'react-dom',
124
+ '@tanstack/react-query',
125
+ ...GUI_PACKAGES,
126
+ ];
127
+
128
+ /**
129
+ * Exact-match aliases pinning every deduped package to ONE entry file, for
130
+ * bundlers that have no `dedupe` of their own (webpack, turbopack).
110
131
  *
111
132
  * Keys carry webpack's `$` exact-match suffix so only the bare specifier is
112
133
  * redirected — a deep import keeps resolving normally. Packages that are not
@@ -119,7 +140,7 @@ export function guiAliases(from: string): Record<string, string> {
119
140
  const { createRequire } = require('node:module') as typeof import('node:module');
120
141
  const require_ = createRequire(`${ from.replace(/\/?$/, '/') }noop.js`);
121
142
  const alias: Record<string, string> = {};
122
- for (const pkg of GUI_PACKAGES) {
143
+ for (const pkg of ENGINE_DEDUPE) {
123
144
  try {
124
145
  alias[`${ pkg }$`] = require_.resolve(pkg);
125
146
  } catch {
package/src/index.ts CHANGED
@@ -199,7 +199,7 @@ export type { SelectOption, ListCollection, SelectRootProps, SelectProps, Select
199
199
  export { Separator } from './separator';
200
200
  export type { SeparatorProps } from './separator';
201
201
 
202
- export { Skeleton, SkeletonCircle, SkeletonText } from './skeleton';
202
+ export { Skeleton, SkeletonCircle, SkeletonText, LoadingBar } from './skeleton';
203
203
  export type { SkeletonProps, SkeletonCircleProps, SkeletonTextProps } from './skeleton';
204
204
 
205
205
  export { Slider } from './slider';
package/src/next.ts CHANGED
@@ -49,6 +49,15 @@ export function withLuxUi(config: LuxUiNextConfig = {}): LuxUiNextConfig {
49
49
  // so the equivalent is an exact-match alias per package, resolved from the app
50
50
  // root — see ./engine for why two copies is a crash and not a size problem.
51
51
  const engine = guiAliases(config.dir ?? process.cwd());
52
+ // The same dedupe for Turbopack, which is the ONLY dev bundler from Next 16 —
53
+ // `webpack()` below is never called by `next dev`, so a webpack-only alias map
54
+ // deduped production and left development with N copies of `@hanzogui/popper`
55
+ // and the `Cannot read properties of undefined (reading 'setReference')`
56
+ // storm that comes with them. Turbopack has no `$` exact-match suffix; a bare
57
+ // key is already exact, so strip it.
58
+ const engineTurbopack = Object.fromEntries(
59
+ Object.entries(engine).map(([ k, v ]) => [ k.replace(/\$$/, ''), v ]),
60
+ );
52
61
  // `dir` is OURS, not Next's — leaving it on the returned object makes Next
53
62
  // print `Unrecognized key(s) in object: 'dir'` on every boot of every surface
54
63
  // that passes it. Consume it here.
@@ -60,6 +69,7 @@ export function withLuxUi(config: LuxUiNextConfig = {}): LuxUiNextConfig {
60
69
  ...config.turbopack,
61
70
  resolveAlias: {
62
71
  'react-native': 'react-native-web',
72
+ ...engineTurbopack,
63
73
  ...config.turbopack?.resolveAlias,
64
74
  },
65
75
  },
package/src/skeleton.tsx CHANGED
@@ -142,11 +142,19 @@ function stripShims<T extends Record<string, unknown>>(props: T): Omit<T, (typeo
142
142
  return out as Omit<T, (typeof SHIM_PROPS)[number]>;
143
143
  }
144
144
 
145
- const SKELETON_CLASSES = [
146
- 'animate-skeleton-shimmer rounded-sm',
147
- 'bg-[linear-gradient(90deg,var(--color-skeleton-start)_0%,var(--color-skeleton-end)_50%,var(--color-skeleton-start)_100%)]',
148
- 'bg-[length:200%_100%]',
149
- ].join(' ');
145
+ // A skeleton reserves space. It does not move.
146
+ //
147
+ // It used to shimmer via a per-element `background-position` animation, which
148
+ // repaints the element's whole box every frame. Under a 4x CPU throttle that
149
+ // costs 33.6fps at 1460 concurrent skeletons and 13.8fps at 3000. Rewriting it
150
+ // as a per-element `transform` sheen — the textbook "animate transform only"
151
+ // fix — measured WORSE: 23.0fps and 5.4fps, because it also adds a layer and a
152
+ // clip per element. Holding still costs 120fps at every n.
153
+ //
154
+ // The "work is in flight" signal is <LoadingBar/> below: one animated element
155
+ // for the whole page instead of one per skeleton. Full numbers and reasoning
156
+ // live next to the CSS, in section 6c of @luxfi/ui/tokens.css.
157
+ const SKELETON_CLASSES = 'lux-skeleton rounded-sm';
150
158
 
151
159
  const HIDE_BELOW_MAP: Record<string, string> = { lg: 'lg:hidden', md: 'md:hidden', sm: 'sm:hidden' };
152
160
 
@@ -176,7 +184,7 @@ export const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
176
184
  <Component
177
185
  ref={ ref }
178
186
  data-loading
179
- className={ cn(SKELETON_CLASSES, 'text-transparent [&_*]:invisible', cls) }
187
+ className={ cn(SKELETON_CLASSES, '[&_*]:invisible', cls) }
180
188
  style={ mergedStyle }
181
189
  { ...htmlRest }
182
190
  >
@@ -189,7 +197,7 @@ export const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
189
197
  <Component
190
198
  ref={ ref }
191
199
  data-loading
192
- className={ cn(SKELETON_CLASSES, children ? 'text-transparent [&_*]:invisible' : 'min-h-5', cls) }
200
+ className={ cn(SKELETON_CLASSES, children ? '[&_*]:invisible' : 'min-h-5', cls) }
193
201
  style={ mergedStyle }
194
202
  { ...htmlRest }
195
203
  >
@@ -252,3 +260,24 @@ export const SkeletonText = React.forwardRef<HTMLDivElement, SkeletonTextProps>(
252
260
  );
253
261
  },
254
262
  );
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // LoadingBar
266
+ // ---------------------------------------------------------------------------
267
+
268
+ /**
269
+ * The page-level "work is in flight" signal — one animated element, however
270
+ * many skeletons are on screen. Mount it while any request is outstanding.
271
+ *
272
+ * It is `position: fixed` and 2px tall, so it is out of flow: it cannot shift
273
+ * layout (CLS 0) and it cannot tint content. Under prefers-reduced-motion the
274
+ * sweep stops and the track stays lit, so the state is still legible.
275
+ *
276
+ * This is what replaces a bare spinner. A spinner says "something is
277
+ * happening somewhere"; a skeleton plus this bar says "here is the shape of
278
+ * what is coming, and it is still coming".
279
+ */
280
+ export function LoadingBar({ loading = true }: { readonly loading?: boolean }) {
281
+ if (!loading) return null;
282
+ return <div className="lux-loading" role="progressbar" aria-busy="true" aria-label="Loading"/>;
283
+ }
package/src/tooltip.tsx CHANGED
@@ -138,13 +138,21 @@ export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
138
138
  // ONE ref, always the same one. Swapping between `triggerRef` and
139
139
  // `undefined` on open/close changes the composed-ref identity, so
140
140
  // React detaches and re-attaches the popper's reference element on
141
- // every hover — two extra `refs.setReference()` calls per open, each
142
- // of which throws uncaught when the Popper context is not the one
143
- // this anchor is reading (see ./engine). The click-away handler
144
- // already no-ops while closed, so the ref never needed to move.
141
+ // every hover — two extra `refs.setReference()` calls per open. Each
142
+ // of those throws uncaught when the Popper context is not the one
143
+ // this anchor is reading, which is what two physical copies of
144
+ // `@hanzogui/popper` produce (see ./engine that duplication, not
145
+ // this ref, is the root cause). The click-away handler already
146
+ // no-ops while closed, so the ref never needed to move.
145
147
  ref={ triggerRef }
146
148
  asChild
147
- {...(isMobile ? { onPress: handleTriggerClick } : {})}
149
+ // `asChild` renders the CONSUMER's node on the web that is a DOM
150
+ // element, so the tap handler has to be a DOM event. `onPress` is a
151
+ // React Native prop: it survives the clone, reaches the element and
152
+ // React logs "Unknown event handler property `onPress`. It will be
153
+ // ignored." — an uncatchable console error AND a dead tap target on
154
+ // every touch device.
155
+ {...(isMobile ? { onClick: handleTriggerClick } : {})}
148
156
  { ...triggerProps as any }
149
157
  >
150
158
  { children }