@luxfi/ui 7.4.2 → 7.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,10 +24,17 @@ var ORGS = {
24
24
  org: "zoo",
25
25
  name: "Zoo",
26
26
  domain: "zoo.ngo",
27
- // Zoo's IdP is `id.zoo.network`, not a `zoo.id` apex — the one row in this
28
- // table where the issuer is not `<org>.id`, and the one every hand-rolled
29
- // per-app branding map got wrong.
30
- iamDomain: "id.zoo.network",
27
+ // Zoo's issuer is `zoolabs.id` — the one row where it is not `<org>.id`,
28
+ // and the one every hand-rolled branding map gets wrong. `id.zoo.network`
29
+ // ANSWERS, which is why it keeps being copied around, but it is an alias:
30
+ // its own discovery document advertises
31
+ // issuer https://zoolabs.id
32
+ // authorization_endpoint https://zoolabs.id/v1/iam/oauth/authorize
33
+ // jwks_uri https://zoolabs.id/v1/iam/.well-known/jwks
34
+ // so a client configured on the alias validates `iss` against a string the
35
+ // IdP never emits and rejects every token it is given — the exact shape of
36
+ // the lux.id outage. `zoo.id` is not an IdP at all (no discovery document).
37
+ iamDomain: "zoolabs.id",
31
38
  accent: LUX_BRAND.zoo,
32
39
  accentForeground: "#FFFFFF"
33
40
  },
@@ -61,15 +68,18 @@ var DOMAIN_ORG = [
61
68
  ["lux.cloud", "lux"],
62
69
  ["lux.id", "lux"],
63
70
  ["lux.finance", "lux"],
71
+ ["lux.market", "lux"],
72
+ ["lux.exchange", "lux"],
64
73
  ["zoo.ngo", "zoo"],
65
74
  ["zoo.network", "zoo"],
66
75
  ["zoo.cloud", "zoo"],
67
- ["zoo.id", "zoo"],
76
+ ["zoolabs.id", "zoo"],
68
77
  ["hanzo.ai", "hanzo"],
69
78
  ["hanzo.cloud", "hanzo"],
70
79
  ["hanzo.id", "hanzo"],
71
- ["pars.id", "pars"],
72
- ["parsdao.org", "pars"],
80
+ ["pars.id", "pars", "app"],
81
+ ["pars.network", "pars", "app"],
82
+ ["parsdao.org", "pars", "app"],
73
83
  ["bootno.de", "bootnode", "platform"]
74
84
  ];
75
85
  var DEFAULT_ORG = "lux";
@@ -89,11 +99,11 @@ function matchDomain(host) {
89
99
  }
90
100
  return best;
91
101
  }
92
- function appSlug(host, domain, apexApp) {
102
+ function appSlug(host, domain, pinned) {
103
+ if (pinned) return pinned;
93
104
  const sub = host === domain ? "" : host.slice(0, -(domain.length + 1));
94
105
  const leftmost = sub.split(".")[0] ?? "";
95
106
  if (leftmost && leftmost !== "www") return leftmost;
96
- if (apexApp) return apexApp;
97
107
  const label = domain.split(".")[0] ?? "";
98
108
  const tld = domain.split(".").slice(1).join(".");
99
109
  return tld.split(".")[0] || label;
@@ -42,9 +42,9 @@ interface WhiteLabel extends OrgIdentity {
42
42
  * Pure and total: an unknown or missing host falls back to the Lux row rather
43
43
  * than throwing, so a preview URL or a health probe never blanks the UI.
44
44
  *
45
- * resolveWhiteLabel('mpc.lux.cloud') // org lux, clientId lux-mpc, issuer https://lux.id
46
- * resolveWhiteLabel('zoo.network') // org zoo, clientId zoo-network, issuer https://zoo.id
47
- * resolveWhiteLabel('id.pars.id') // org pars, clientId pars-id, issuer https://pars.id
45
+ * resolveWhiteLabel('mpc.lux.network') // org lux, clientId lux-mpc, issuer https://lux.id
46
+ * resolveWhiteLabel('zoo.cloud') // org zoo, clientId zoo-cloud, issuer https://zoolabs.id
47
+ * resolveWhiteLabel('cloud.pars.network') // org pars, clientId pars-app, issuer https://pars.id
48
48
  */
49
49
  declare function resolveWhiteLabel(host?: string | null): WhiteLabel;
50
50
  /**
@@ -42,9 +42,9 @@ interface WhiteLabel extends OrgIdentity {
42
42
  * Pure and total: an unknown or missing host falls back to the Lux row rather
43
43
  * than throwing, so a preview URL or a health probe never blanks the UI.
44
44
  *
45
- * resolveWhiteLabel('mpc.lux.cloud') // org lux, clientId lux-mpc, issuer https://lux.id
46
- * resolveWhiteLabel('zoo.network') // org zoo, clientId zoo-network, issuer https://zoo.id
47
- * resolveWhiteLabel('id.pars.id') // org pars, clientId pars-id, issuer https://pars.id
45
+ * resolveWhiteLabel('mpc.lux.network') // org lux, clientId lux-mpc, issuer https://lux.id
46
+ * resolveWhiteLabel('zoo.cloud') // org zoo, clientId zoo-cloud, issuer https://zoolabs.id
47
+ * resolveWhiteLabel('cloud.pars.network') // org pars, clientId pars-app, issuer https://pars.id
48
48
  */
49
49
  declare function resolveWhiteLabel(host?: string | null): WhiteLabel;
50
50
  /**
@@ -22,10 +22,17 @@ var ORGS = {
22
22
  org: "zoo",
23
23
  name: "Zoo",
24
24
  domain: "zoo.ngo",
25
- // Zoo's IdP is `id.zoo.network`, not a `zoo.id` apex — the one row in this
26
- // table where the issuer is not `<org>.id`, and the one every hand-rolled
27
- // per-app branding map got wrong.
28
- iamDomain: "id.zoo.network",
25
+ // Zoo's issuer is `zoolabs.id` — the one row where it is not `<org>.id`,
26
+ // and the one every hand-rolled branding map gets wrong. `id.zoo.network`
27
+ // ANSWERS, which is why it keeps being copied around, but it is an alias:
28
+ // its own discovery document advertises
29
+ // issuer https://zoolabs.id
30
+ // authorization_endpoint https://zoolabs.id/v1/iam/oauth/authorize
31
+ // jwks_uri https://zoolabs.id/v1/iam/.well-known/jwks
32
+ // so a client configured on the alias validates `iss` against a string the
33
+ // IdP never emits and rejects every token it is given — the exact shape of
34
+ // the lux.id outage. `zoo.id` is not an IdP at all (no discovery document).
35
+ iamDomain: "zoolabs.id",
29
36
  accent: LUX_BRAND.zoo,
30
37
  accentForeground: "#FFFFFF"
31
38
  },
@@ -59,15 +66,18 @@ var DOMAIN_ORG = [
59
66
  ["lux.cloud", "lux"],
60
67
  ["lux.id", "lux"],
61
68
  ["lux.finance", "lux"],
69
+ ["lux.market", "lux"],
70
+ ["lux.exchange", "lux"],
62
71
  ["zoo.ngo", "zoo"],
63
72
  ["zoo.network", "zoo"],
64
73
  ["zoo.cloud", "zoo"],
65
- ["zoo.id", "zoo"],
74
+ ["zoolabs.id", "zoo"],
66
75
  ["hanzo.ai", "hanzo"],
67
76
  ["hanzo.cloud", "hanzo"],
68
77
  ["hanzo.id", "hanzo"],
69
- ["pars.id", "pars"],
70
- ["parsdao.org", "pars"],
78
+ ["pars.id", "pars", "app"],
79
+ ["pars.network", "pars", "app"],
80
+ ["parsdao.org", "pars", "app"],
71
81
  ["bootno.de", "bootnode", "platform"]
72
82
  ];
73
83
  var DEFAULT_ORG = "lux";
@@ -87,11 +97,11 @@ function matchDomain(host) {
87
97
  }
88
98
  return best;
89
99
  }
90
- function appSlug(host, domain, apexApp) {
100
+ function appSlug(host, domain, pinned) {
101
+ if (pinned) return pinned;
91
102
  const sub = host === domain ? "" : host.slice(0, -(domain.length + 1));
92
103
  const leftmost = sub.split(".")[0] ?? "";
93
104
  if (leftmost && leftmost !== "www") return leftmost;
94
- if (apexApp) return apexApp;
95
105
  const label = domain.split(".")[0] ?? "";
96
106
  const tld = domain.split(".").slice(1).join(".");
97
107
  return tld.split(".")[0] || label;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luxfi/ui",
3
- "version": "7.4.2",
3
+ "version": "7.4.4",
4
4
  "description": "Cross-platform DeFi UI components built on @hanzo/gui (Tamagui). Native mobile + web.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/engine.ts ADDED
@@ -0,0 +1,130 @@
1
+ // The gui engine, named once.
2
+ //
3
+ // @hanzo/gui is a facade over ~55 `@hanzogui/*` packages, and several of them
4
+ // (`popper`, `popover`, `core`, `floating`) publish React contexts at MODULE
5
+ // scope. Two physical copies of such a package are two different context
6
+ // objects, so a provider from copy A is invisible to a consumer from copy B and
7
+ // the consumer silently falls back to the context's default value.
8
+ //
9
+ // That is not a theoretical hazard, it is the shape of the loudest bug this
10
+ // package has shipped: `PopperAnchor` reads `refs` off the Popper context and
11
+ // calls `refs.setReference(node)` from a ref callback with no guard, so when the
12
+ // context is the empty default the browser logs
13
+ //
14
+ // Cannot read properties of undefined (reading 'setReference')
15
+ //
16
+ // once per tooltip trigger, uncaught (it runs inside `startTransition`, so no
17
+ // error boundary sees it). A page with a tooltip per table row logs it hundreds
18
+ // of times. The gui engine itself diagnoses the same root cause out loud when
19
+ // the duplication reaches its config lookup: "Can't find Hanzo GUI
20
+ // configuration … due to having mis-matched versions of Hanzo GUI dependencies,
21
+ // or bundlers somehow duplicating them."
22
+ //
23
+ // So the engine list is a VALUE, declared once here, and both bundler wrappers
24
+ // (`@luxfi/ui/vite`, `@luxfi/ui/next`) dedupe against it. A consumer that uses
25
+ // the wrapper cannot end up with two copies, and does not have to hand-write a
26
+ // per-app `pnpm.overrides` block pinning three dozen packages to stop it.
27
+
28
+ /** Every package `@hanzo/gui` re-exports — the engine, in full. */
29
+ export const GUI_PACKAGES: ReadonlyArray<string> = [
30
+ '@hanzo/gui',
31
+ '@hanzogui/accordion',
32
+ '@hanzogui/adapt',
33
+ '@hanzogui/alert-dialog',
34
+ '@hanzogui/animate',
35
+ '@hanzogui/animate-presence',
36
+ '@hanzogui/avatar',
37
+ '@hanzogui/button',
38
+ '@hanzogui/card',
39
+ '@hanzogui/checkbox',
40
+ '@hanzogui/collapsible',
41
+ '@hanzogui/component-helpers',
42
+ '@hanzogui/compose-refs',
43
+ '@hanzogui/config',
44
+ '@hanzogui/context-menu',
45
+ '@hanzogui/core',
46
+ '@hanzogui/create-context',
47
+ '@hanzogui/create-menu',
48
+ '@hanzogui/dialog',
49
+ '@hanzogui/dismissable',
50
+ '@hanzogui/element',
51
+ '@hanzogui/elements',
52
+ '@hanzogui/floating',
53
+ '@hanzogui/focus-scope',
54
+ '@hanzogui/font-size',
55
+ '@hanzogui/form',
56
+ '@hanzogui/get-token',
57
+ '@hanzogui/group',
58
+ '@hanzogui/helpers',
59
+ '@hanzogui/image',
60
+ '@hanzogui/input',
61
+ '@hanzogui/label',
62
+ '@hanzogui/list-item',
63
+ '@hanzogui/menu',
64
+ '@hanzogui/popover',
65
+ '@hanzogui/popper',
66
+ '@hanzogui/portal',
67
+ '@hanzogui/progress',
68
+ '@hanzogui/radio-group',
69
+ '@hanzogui/react-native-media-driver',
70
+ '@hanzogui/remove-scroll',
71
+ '@hanzogui/scroll-view',
72
+ '@hanzogui/select',
73
+ '@hanzogui/separator',
74
+ '@hanzogui/shapes',
75
+ '@hanzogui/sheet',
76
+ '@hanzogui/slider',
77
+ '@hanzogui/spacer',
78
+ '@hanzogui/spinner',
79
+ '@hanzogui/stacks',
80
+ '@hanzogui/switch',
81
+ '@hanzogui/tabs',
82
+ '@hanzogui/text',
83
+ '@hanzogui/theme',
84
+ '@hanzogui/toast',
85
+ '@hanzogui/toggle-group',
86
+ '@hanzogui/tooltip',
87
+ '@hanzogui/use-controllable-state',
88
+ '@hanzogui/use-debounce',
89
+ '@hanzogui/use-force-update',
90
+ '@hanzogui/use-presence',
91
+ '@hanzogui/use-window-dimensions',
92
+ '@hanzogui/visually-hidden',
93
+ '@hanzogui/web',
94
+ '@hanzogui/z-index-stack',
95
+ '@luxfi/ui',
96
+ ];
97
+
98
+ /**
99
+ * The engine branches on these at module scope; without them a browser bundle
100
+ * takes the native path.
101
+ */
102
+ export const GUI_DEFINES: Readonly<Record<string, string>> = {
103
+ 'process.env.EXPO_OS': JSON.stringify('web'),
104
+ 'process.env.IS_WEB': JSON.stringify('true'),
105
+ };
106
+
107
+ /**
108
+ * Exact-match aliases pinning every engine package to ONE entry file, for
109
+ * bundlers that have no `dedupe` of their own (webpack).
110
+ *
111
+ * Keys carry webpack's `$` exact-match suffix so only the bare specifier is
112
+ * redirected — a deep import keeps resolving normally. Packages that are not
113
+ * physically installed are skipped rather than throwing: the list is the
114
+ * engine's full surface and no single app pulls all of it.
115
+ */
116
+ export function guiAliases(from: string): Record<string, string> {
117
+ // Deferred so this module stays importable from a browser bundle (the
118
+ // wrappers are build-time only, but `engine.ts` is plain values).
119
+ const { createRequire } = require('node:module') as typeof import('node:module');
120
+ const require_ = createRequire(`${ from.replace(/\/?$/, '/') }noop.js`);
121
+ const alias: Record<string, string> = {};
122
+ for (const pkg of GUI_PACKAGES) {
123
+ try {
124
+ alias[`${ pkg }$`] = require_.resolve(pkg);
125
+ } catch {
126
+ // Not installed in this app — nothing to dedupe.
127
+ }
128
+ }
129
+ return alias;
130
+ }
package/src/iam.ts CHANGED
@@ -70,11 +70,25 @@ export class IamError extends Error {
70
70
  }
71
71
  }
72
72
 
73
- /** IAM answers 401, or 200 with a sign-in message. Both mean "no session". */
73
+ /**
74
+ * "No session" — as opposed to "a session that may not do this".
75
+ *
76
+ * IAM says it two ways: 401, and (measured against lux.id) HTTP **200** with
77
+ * `{"status":"error","msg":"please sign in first"}`, which is what both a
78
+ * missing and a malformed bearer get. So the message has to be read, not just
79
+ * the status.
80
+ *
81
+ * 403 is deliberately NOT here. `get-organizations` answers 403 to an ordinary
82
+ * signed-in user — listing the tenant table is a super-admin read — and folding
83
+ * that into "signed out" logged the user out of the chrome: get-account had
84
+ * already succeeded, then the org read's 403 wiped the account and every
85
+ * switcher offered "Sign in" to somebody who was signed in. Forbidden means
86
+ * signed in and not allowed; the caller falls back to the org it can see.
87
+ */
74
88
  const SIGNED_OUT = /please sign in|authentication required|unauthorized/i;
75
89
 
76
90
  function isSignedOut(status: number, msg: string): boolean {
77
- return status === 401 || status === 403 || SIGNED_OUT.test(msg);
91
+ return status === 401 || SIGNED_OUT.test(msg);
78
92
  }
79
93
 
80
94
  /** The IAM API root for a host — `lux.cloud` → `https://lux.id/v1/iam`. */
package/src/identity.tsx CHANGED
@@ -181,8 +181,12 @@ export const IdentityProvider = ({
181
181
  return;
182
182
  }
183
183
  setAccount(me);
184
- // A member who cannot list the tenant table still has their OWN org:
185
- // fall back to the one the account names rather than showing nothing.
184
+ // A member who cannot list the tenant table still has their OWN org.
185
+ // `get-organizations` is a super-admin read and answers 403 to everyone
186
+ // else (measured on lux.id with a real z@lux.network bearer), so this is
187
+ // the COMMON path, not an edge case: fall back to the org the account
188
+ // itself names rather than showing an empty switcher — or, worse,
189
+ // treating the refusal as "not signed in".
186
190
  const rows = await iam.organizations(call).catch((e: unknown) => {
187
191
  if (e instanceof IamError && !e.unauthenticated) return [ { owner: 'admin', name: me.owner } ];
188
192
  throw e;
package/src/next.ts CHANGED
@@ -18,73 +18,7 @@
18
18
  // Node-only (a build-time module) and dependency-free: it takes and returns a
19
19
  // plain object, so it does not force `next` into anyone's type graph.
20
20
 
21
- /** Every package `@hanzo/gui` re-exports the engine, in full. */
22
- const GUI_PACKAGES = [
23
- '@hanzo/gui',
24
- '@hanzogui/accordion',
25
- '@hanzogui/adapt',
26
- '@hanzogui/alert-dialog',
27
- '@hanzogui/animate',
28
- '@hanzogui/animate-presence',
29
- '@hanzogui/avatar',
30
- '@hanzogui/button',
31
- '@hanzogui/card',
32
- '@hanzogui/checkbox',
33
- '@hanzogui/collapsible',
34
- '@hanzogui/component-helpers',
35
- '@hanzogui/compose-refs',
36
- '@hanzogui/config',
37
- '@hanzogui/context-menu',
38
- '@hanzogui/core',
39
- '@hanzogui/create-context',
40
- '@hanzogui/create-menu',
41
- '@hanzogui/dialog',
42
- '@hanzogui/element',
43
- '@hanzogui/elements',
44
- '@hanzogui/font-size',
45
- '@hanzogui/form',
46
- '@hanzogui/group',
47
- '@hanzogui/image',
48
- '@hanzogui/input',
49
- '@hanzogui/label',
50
- '@hanzogui/list-item',
51
- '@hanzogui/menu',
52
- '@hanzogui/popover',
53
- '@hanzogui/popper',
54
- '@hanzogui/portal',
55
- '@hanzogui/progress',
56
- '@hanzogui/radio-group',
57
- '@hanzogui/react-native-media-driver',
58
- '@hanzogui/scroll-view',
59
- '@hanzogui/select',
60
- '@hanzogui/separator',
61
- '@hanzogui/shapes',
62
- '@hanzogui/sheet',
63
- '@hanzogui/slider',
64
- '@hanzogui/spacer',
65
- '@hanzogui/spinner',
66
- '@hanzogui/stacks',
67
- '@hanzogui/switch',
68
- '@hanzogui/tabs',
69
- '@hanzogui/text',
70
- '@hanzogui/theme',
71
- '@hanzogui/toast',
72
- '@hanzogui/toggle-group',
73
- '@hanzogui/tooltip',
74
- '@hanzogui/use-controllable-state',
75
- '@hanzogui/use-debounce',
76
- '@hanzogui/use-force-update',
77
- '@hanzogui/use-window-dimensions',
78
- '@hanzogui/visually-hidden',
79
- '@hanzogui/web',
80
- '@luxfi/ui',
81
- ];
82
-
83
- /** The engine branches on these at module scope. */
84
- const DEFINES: Record<string, string> = {
85
- 'process.env.EXPO_OS': JSON.stringify('web'),
86
- 'process.env.IS_WEB': JSON.stringify('true'),
87
- };
21
+ import { guiAliases, GUI_DEFINES, GUI_PACKAGES } from './engine';
88
22
 
89
23
  interface WebpackConfig {
90
24
  resolve?: { alias?: Record<string, unknown> };
@@ -94,6 +28,8 @@ interface WebpackConfig {
94
28
 
95
29
  /** The structural slice of a Next config this wrapper touches. */
96
30
  export interface LuxUiNextConfig {
31
+ /** App root, for resolving the engine's single copy. Defaults to `cwd`. */
32
+ dir?: string;
97
33
  transpilePackages?: Array<string>;
98
34
  turbopack?: { resolveAlias?: Record<string, string>; [key: string]: unknown };
99
35
  env?: Record<string, string>;
@@ -109,8 +45,16 @@ export interface LuxUiNextConfig {
109
45
  */
110
46
  export function withLuxUi(config: LuxUiNextConfig = {}): LuxUiNextConfig {
111
47
  const userWebpack = config.webpack;
48
+ // ONE physical copy of every engine package. Webpack has no `resolve.dedupe`,
49
+ // so the equivalent is an exact-match alias per package, resolved from the app
50
+ // root — see ./engine for why two copies is a crash and not a size problem.
51
+ const engine = guiAliases(config.dir ?? process.cwd());
52
+ // `dir` is OURS, not Next's — leaving it on the returned object makes Next
53
+ // print `Unrecognized key(s) in object: 'dir'` on every boot of every surface
54
+ // that passes it. Consume it here.
55
+ const { dir: _dir, ...rest } = config;
112
56
  return {
113
- ...config,
57
+ ...rest,
114
58
  transpilePackages: [ ...new Set([ ...(config.transpilePackages ?? []), ...GUI_PACKAGES ]) ],
115
59
  turbopack: {
116
60
  ...config.turbopack,
@@ -126,13 +70,15 @@ export function withLuxUi(config: LuxUiNextConfig = {}): LuxUiNextConfig {
126
70
  const next = userWebpack ? userWebpack(webpackConfig, context) : webpackConfig;
127
71
  next.resolve = {
128
72
  ...next.resolve,
129
- alias: { ...next.resolve?.alias, 'react-native$': 'react-native-web' },
73
+ // The app's own aliases win — an app pinning a package on purpose is not
74
+ // something the umbrella should override.
75
+ alias: { 'react-native$': 'react-native-web', ...engine, ...next.resolve?.alias },
130
76
  };
131
77
  // `context` is Next's `{ webpack }` bag — use ITS webpack instance so the
132
78
  // DefinePlugin is the one running the build.
133
79
  const webpackModule = (context as { webpack?: { DefinePlugin?: new (d: Record<string, string>) => unknown } })?.webpack;
134
80
  if (webpackModule?.DefinePlugin) {
135
- next.plugins = [ ...(next.plugins ?? []), new webpackModule.DefinePlugin(DEFINES) ];
81
+ next.plugins = [ ...(next.plugins ?? []), new webpackModule.DefinePlugin({ ...GUI_DEFINES }) ];
136
82
  }
137
83
  return next;
138
84
  },
@@ -140,3 +86,4 @@ export function withLuxUi(config: LuxUiNextConfig = {}): LuxUiNextConfig {
140
86
  }
141
87
 
142
88
  export { GUI_PACKAGES };
89
+
package/src/tooltip.tsx CHANGED
@@ -82,9 +82,12 @@ export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
82
82
  }, nextOpen ? openDelay : closeDelay);
83
83
  }, [ closeDelay, openDelay, onOpenChange ]);
84
84
 
85
+ // `useClickAway` keeps the LATEST callback in a ref and returns a ref whose
86
+ // identity never changes, so this may read `open` directly — and must, so a
87
+ // closed tooltip does not arm a close timer on every document click.
85
88
  const handleClickAway = React.useCallback(() => {
86
- handleOpenChangeManual(false);
87
- }, [ handleOpenChangeManual ]);
89
+ if (open) handleOpenChangeManual(false);
90
+ }, [ open, handleOpenChangeManual ]);
88
91
 
89
92
  const triggerRef = useClickAway<HTMLButtonElement>(handleClickAway);
90
93
 
@@ -132,7 +135,14 @@ export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
132
135
  unstyled
133
136
  >
134
137
  <GuiTooltip.Trigger
135
- ref={ open ? triggerRef : undefined }
138
+ // ONE ref, always the same one. Swapping between `triggerRef` and
139
+ // `undefined` on open/close changes the composed-ref identity, so
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.
145
+ ref={ triggerRef }
136
146
  asChild
137
147
  {...(isMobile ? { onPress: handleTriggerClick } : {})}
138
148
  { ...triggerProps as any }
package/src/vite.ts CHANGED
@@ -14,6 +14,8 @@
14
14
  // Node-only (it is a build-time module) and dependency-free: it returns a plain
15
15
  // Vite plugin object, so it does not force `vite` into anyone's runtime graph.
16
16
 
17
+ import { GUI_DEFINES, GUI_PACKAGES } from './engine';
18
+
17
19
  interface LuxUiViteOptions {
18
20
  /**
19
21
  * Extra packages to keep to a single physical copy. React, the gui engine and
@@ -37,13 +39,16 @@ interface LuxUiVitePlugin {
37
39
  };
38
40
  }
39
41
 
42
+ // The WHOLE engine, not a hand-picked three. `@hanzogui/popper` and
43
+ // `@hanzogui/popover` publish the contexts a Tooltip trigger reads, and they
44
+ // were the two missing from the old list — so a consumer using this plugin
45
+ // could still get two poppers and the `setReference` crash it causes. One list,
46
+ // shared with the Next wrapper. See ./engine.
40
47
  const DEDUPE = [
41
48
  'react',
42
49
  'react-dom',
43
50
  '@tanstack/react-query',
44
- '@hanzo/gui',
45
- '@hanzogui/core',
46
- '@hanzogui/web',
51
+ ...GUI_PACKAGES,
47
52
  ];
48
53
 
49
54
  const INCLUDE = [
@@ -59,11 +64,8 @@ export function luxUi(options: LuxUiViteOptions = {}): LuxUiVitePlugin {
59
64
  name: '@luxfi/ui',
60
65
  config: () => ({
61
66
  // The engine branches on these at module scope; without them it takes the
62
- // native path in a browser bundle.
63
- define: {
64
- 'process.env.EXPO_OS': JSON.stringify('web'),
65
- 'process.env.IS_WEB': JSON.stringify('true'),
66
- },
67
+ // native path in a browser bundle. (./engine)
68
+ define: { ...GUI_DEFINES },
67
69
  resolve: {
68
70
  alias: { 'react-native': 'react-native-web' },
69
71
  dedupe: [ ...new Set([ ...DEDUPE, ...(options.dedupe ?? []) ]) ],
@@ -68,10 +68,17 @@ const ORGS: Readonly<Record<Org, OrgIdentity>> = {
68
68
  org: 'zoo',
69
69
  name: 'Zoo',
70
70
  domain: 'zoo.ngo',
71
- // Zoo's IdP is `id.zoo.network`, not a `zoo.id` apex — the one row in this
72
- // table where the issuer is not `<org>.id`, and the one every hand-rolled
73
- // per-app branding map got wrong.
74
- iamDomain: 'id.zoo.network',
71
+ // Zoo's issuer is `zoolabs.id` — the one row where it is not `<org>.id`,
72
+ // and the one every hand-rolled branding map gets wrong. `id.zoo.network`
73
+ // ANSWERS, which is why it keeps being copied around, but it is an alias:
74
+ // its own discovery document advertises
75
+ // issuer https://zoolabs.id
76
+ // authorization_endpoint https://zoolabs.id/v1/iam/oauth/authorize
77
+ // jwks_uri https://zoolabs.id/v1/iam/.well-known/jwks
78
+ // so a client configured on the alias validates `iss` against a string the
79
+ // IdP never emits and rejects every token it is given — the exact shape of
80
+ // the lux.id outage. `zoo.id` is not an IdP at all (no discovery document).
81
+ iamDomain: 'zoolabs.id',
75
82
  accent: LUX_BRAND.zoo,
76
83
  accentForeground: '#FFFFFF',
77
84
  },
@@ -105,24 +112,31 @@ const ORGS: Readonly<Record<Org, OrgIdentity>> = {
105
112
  // SUFFIX on a label boundary, so `mpc.lux.cloud` and `lux.cloud` both hit `lux`
106
113
  // while `notlux.cloud` does not.
107
114
  //
108
- // The third slot names the app at the domain's APEX. It is usually derivable
109
- // (`lux.cloud` `cloud` client `lux-cloud`), so it is only written where the
110
- // registered client says otherwise `bootno.de` is `bootnode-platform`, not
111
- // `bootnode-de`. A client id is a registration, not a guess.
115
+ // The third slot PINS the registered app slug for every host under the domain.
116
+ // Most brands register one client per app and the slug is derivable from the
117
+ // hostname `lux.cloud` `cloud` `lux-cloud`, `mpc.lux.network` `lux-mpc`,
118
+ // both verified 302 against the live authorize endpoint. Some register ONE
119
+ // client for the whole brand, and then the derivation is a guess that 400s:
120
+ // `bootno.de` is `bootnode-platform`, and Pars is `pars-app` on every host
121
+ // (`pars-cloud` answers 400 where `pars-app` answers 302). A client id is a
122
+ // registration, not a guess — so where it is not derivable it is written down.
112
123
  const DOMAIN_ORG: ReadonlyArray<readonly [string, Org, string?]> = [
113
124
  [ 'lux.network', 'lux' ],
114
125
  [ 'lux.cloud', 'lux' ],
115
126
  [ 'lux.id', 'lux' ],
116
127
  [ 'lux.finance', 'lux' ],
128
+ [ 'lux.market', 'lux' ],
129
+ [ 'lux.exchange', 'lux' ],
117
130
  [ 'zoo.ngo', 'zoo' ],
118
131
  [ 'zoo.network', 'zoo' ],
119
132
  [ 'zoo.cloud', 'zoo' ],
120
- [ 'zoo.id', 'zoo' ],
133
+ [ 'zoolabs.id', 'zoo' ],
121
134
  [ 'hanzo.ai', 'hanzo' ],
122
135
  [ 'hanzo.cloud', 'hanzo' ],
123
136
  [ 'hanzo.id', 'hanzo' ],
124
- [ 'pars.id', 'pars' ],
125
- [ 'parsdao.org', 'pars' ],
137
+ [ 'pars.id', 'pars', 'app' ],
138
+ [ 'pars.network', 'pars', 'app' ],
139
+ [ 'parsdao.org', 'pars', 'app' ],
126
140
  [ 'bootno.de', 'bootnode', 'platform' ],
127
141
  ];
128
142
 
@@ -170,15 +184,15 @@ function matchDomain(host: string): readonly [string, Org, string?] | undefined
170
184
  * `mpc.lux.cloud` → `mpc` (leftmost label)
171
185
  * `lux.cloud` → `cloud` (apex: the service name, not the org)
172
186
  * `www.lux.network`→ `network` (www is chrome, not an app)
187
+ * `bootno.de` → `platform` (pinned: a registration, not a guess)
173
188
  * `localhost` → `local`
174
189
  */
175
- function appSlug(host: string, domain: string, apexApp?: string): string {
190
+ function appSlug(host: string, domain: string, pinned?: string): string {
191
+ if (pinned) return pinned;
176
192
  const sub = host === domain ? '' : host.slice(0, -(domain.length + 1));
177
193
  const leftmost = sub.split('.')[0] ?? '';
178
194
  if (leftmost && leftmost !== 'www') return leftmost;
179
- // Apex (or www) — the registered name when the table gives one, else the
180
- // domain's own service label.
181
- if (apexApp) return apexApp;
195
+ // Apex (or www) — the domain's own service label.
182
196
  const label = domain.split('.')[0] ?? '';
183
197
  const tld = domain.split('.').slice(1).join('.');
184
198
  // `lux.network` → `network`, `lux.cloud` → `cloud`, `pars.id` → `id`.
@@ -203,9 +217,9 @@ function isLocal(host: string): boolean {
203
217
  * Pure and total: an unknown or missing host falls back to the Lux row rather
204
218
  * than throwing, so a preview URL or a health probe never blanks the UI.
205
219
  *
206
- * resolveWhiteLabel('mpc.lux.cloud') // org lux, clientId lux-mpc, issuer https://lux.id
207
- * resolveWhiteLabel('zoo.network') // org zoo, clientId zoo-network, issuer https://zoo.id
208
- * resolveWhiteLabel('id.pars.id') // org pars, clientId pars-id, issuer https://pars.id
220
+ * resolveWhiteLabel('mpc.lux.network') // org lux, clientId lux-mpc, issuer https://lux.id
221
+ * resolveWhiteLabel('zoo.cloud') // org zoo, clientId zoo-cloud, issuer https://zoolabs.id
222
+ * resolveWhiteLabel('cloud.pars.network') // org pars, clientId pars-app, issuer https://pars.id
209
223
  */
210
224
  export function resolveWhiteLabel(host?: string | null): WhiteLabel {
211
225
  const h = normalizeHost(host ?? '');