@luxfi/ui 7.3.2 → 7.4.2

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 (154) hide show
  1. package/dist/alert.cjs +2 -2
  2. package/dist/alert.js +1 -1
  3. package/dist/avatar.cjs +7 -6
  4. package/dist/avatar.js +7 -5
  5. package/dist/badge.cjs +36 -38
  6. package/dist/badge.js +31 -33
  7. package/dist/bank.cjs +13 -13
  8. package/dist/bank.d.cts +31 -24
  9. package/dist/bank.d.ts +31 -24
  10. package/dist/bank.js +1 -1
  11. package/dist/button.cjs +3 -3
  12. package/dist/button.js +1 -1
  13. package/dist/checkbox.cjs +4 -4
  14. package/dist/checkbox.d.cts +3 -2
  15. package/dist/checkbox.d.ts +3 -2
  16. package/dist/checkbox.js +5 -4
  17. package/dist/chrome.cjs +950 -0
  18. package/dist/chrome.d.cts +93 -0
  19. package/dist/chrome.d.ts +93 -0
  20. package/dist/chrome.js +921 -0
  21. package/dist/close-button.cjs +2 -2
  22. package/dist/close-button.js +1 -1
  23. package/dist/dialog.cjs +13 -14
  24. package/dist/dialog.d.cts +58 -5
  25. package/dist/dialog.d.ts +58 -5
  26. package/dist/dialog.js +1 -2
  27. package/dist/drawer.cjs +87 -21
  28. package/dist/drawer.js +85 -19
  29. package/dist/expiration-selector.cjs +3 -2
  30. package/dist/expiration-selector.js +3 -2
  31. package/dist/greeks-display.cjs +6 -6
  32. package/dist/greeks-display.js +6 -6
  33. package/dist/gui.cjs +13 -0
  34. package/dist/gui.d.cts +2 -0
  35. package/dist/gui.d.ts +2 -0
  36. package/dist/gui.js +2 -0
  37. package/dist/heading.cjs +4 -2
  38. package/dist/heading.js +4 -2
  39. package/dist/iam.cjs +206 -0
  40. package/dist/iam.d.cts +65 -0
  41. package/dist/iam.d.ts +65 -0
  42. package/dist/iam.js +201 -0
  43. package/dist/icon-button.cjs +2 -2
  44. package/dist/icon-button.js +1 -1
  45. package/dist/icons.cjs +13 -0
  46. package/dist/icons.d.cts +1 -0
  47. package/dist/icons.d.ts +1 -0
  48. package/dist/icons.js +2 -0
  49. package/dist/identity.cjs +420 -0
  50. package/dist/identity.d.cts +64 -0
  51. package/dist/identity.d.ts +64 -0
  52. package/dist/identity.js +398 -0
  53. package/dist/index.cjs +1699 -860
  54. package/dist/index.d.cts +12 -4
  55. package/dist/index.d.ts +12 -4
  56. package/dist/index.js +1618 -802
  57. package/dist/input.cjs +13 -3
  58. package/dist/input.d.cts +6 -0
  59. package/dist/input.d.ts +6 -0
  60. package/dist/input.js +13 -3
  61. package/dist/lux.config.cjs +77 -0
  62. package/dist/lux.config.d.cts +433 -0
  63. package/dist/lux.config.d.ts +433 -0
  64. package/dist/lux.config.js +69 -0
  65. package/dist/menu.cjs +4 -4
  66. package/dist/menu.js +4 -4
  67. package/dist/next.cjs +99 -0
  68. package/dist/next.d.cts +29 -0
  69. package/dist/next.d.ts +29 -0
  70. package/dist/next.js +96 -0
  71. package/dist/option-chain.cjs +1 -1
  72. package/dist/option-chain.js +1 -1
  73. package/dist/option-position.cjs +2 -1
  74. package/dist/option-position.js +2 -1
  75. package/dist/pnl-diagram.cjs +8 -6
  76. package/dist/pnl-diagram.js +8 -6
  77. package/dist/popover.cjs +8 -9
  78. package/dist/popover.js +1 -2
  79. package/dist/progress.cjs +7 -6
  80. package/dist/progress.js +7 -5
  81. package/dist/provider.cjs +199 -5
  82. package/dist/provider.d.cts +16 -3
  83. package/dist/provider.d.ts +16 -3
  84. package/dist/provider.js +195 -6
  85. package/dist/radio.cjs +9 -7
  86. package/dist/radio.js +10 -7
  87. package/dist/separator.cjs +3 -1
  88. package/dist/separator.js +3 -1
  89. package/dist/slider.cjs +28 -16
  90. package/dist/slider.js +28 -15
  91. package/dist/strategy-builder.cjs +2 -1
  92. package/dist/strategy-builder.js +2 -1
  93. package/dist/switch.cjs +11 -12
  94. package/dist/switch.js +12 -12
  95. package/dist/tag.cjs +38 -41
  96. package/dist/tag.js +31 -34
  97. package/dist/textarea.cjs +13 -3
  98. package/dist/textarea.d.cts +2 -0
  99. package/dist/textarea.d.ts +2 -0
  100. package/dist/textarea.js +13 -3
  101. package/dist/tooltip.cjs +31 -33
  102. package/dist/tooltip.js +25 -27
  103. package/dist/use-narrow.cjs +46 -0
  104. package/dist/use-narrow.d.cts +12 -0
  105. package/dist/use-narrow.d.ts +12 -0
  106. package/dist/use-narrow.js +20 -0
  107. package/dist/vite.cjs +40 -0
  108. package/dist/vite.d.cts +29 -0
  109. package/dist/vite.d.ts +29 -0
  110. package/dist/vite.js +38 -0
  111. package/dist/white-label.cjs +132 -0
  112. package/dist/white-label.d.cts +59 -0
  113. package/dist/white-label.d.ts +59 -0
  114. package/dist/white-label.js +124 -0
  115. package/package.json +95 -17
  116. package/src/avatar.tsx +22 -16
  117. package/src/bank.tsx +2 -2
  118. package/src/button.tsx +1 -1
  119. package/src/checkbox.tsx +15 -10
  120. package/src/chrome.tsx +459 -0
  121. package/src/close-button.tsx +1 -1
  122. package/src/dialog.tsx +1 -1
  123. package/src/drawer.tsx +83 -10
  124. package/src/expiration-selector.tsx +3 -2
  125. package/src/greeks-display.tsx +9 -6
  126. package/src/gui.ts +17 -0
  127. package/src/heading.tsx +12 -8
  128. package/src/iam.ts +170 -0
  129. package/src/icon-button.tsx +1 -1
  130. package/src/icons.ts +18 -0
  131. package/src/identity.tsx +318 -0
  132. package/src/index.ts +50 -1
  133. package/src/input.tsx +24 -4
  134. package/src/lux.config.ts +82 -0
  135. package/src/menu.tsx +4 -4
  136. package/src/next.ts +142 -0
  137. package/src/option-chain.tsx +1 -1
  138. package/src/option-position.tsx +2 -1
  139. package/src/pnl-diagram.tsx +11 -6
  140. package/src/popover.tsx +1 -1
  141. package/src/progress.tsx +15 -8
  142. package/src/provider.tsx +92 -12
  143. package/src/radio.tsx +15 -11
  144. package/src/separator.tsx +8 -3
  145. package/src/slider.tsx +35 -19
  146. package/src/strategy-builder.tsx +3 -2
  147. package/src/switch.tsx +17 -16
  148. package/src/textarea.tsx +18 -4
  149. package/src/tooltip.tsx +4 -23
  150. package/src/use-narrow.ts +40 -0
  151. package/src/vite.ts +78 -0
  152. package/src/white-label.ts +240 -0
  153. package/tokens.css +544 -319
  154. package/src/tokens.css +0 -438
package/src/slider.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import * as RadixSlider from '@radix-ui/react-slider';
1
+ import { Slider as GuiSlider } from '@hanzo/gui';
2
2
  import * as React from 'react';
3
3
 
4
4
  import { cn } from './utils';
@@ -20,9 +20,10 @@ export interface SliderProps {
20
20
  readonly className?: string;
21
21
  }
22
22
 
23
- const THUMB_SIZE = 20;
24
- const TRACK_HEIGHT = 6;
25
-
23
+ // gui's Slider: pointer, touch and keyboard from one implementation, with the
24
+ // drag handled as a pan gesture by the engine rather than by mouse events — so it
25
+ // works under a finger, not only under a cursor. `role=slider` and the arrow-key
26
+ // contract come from the primitive.
26
27
  export const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
27
28
  function Slider(props, ref) {
28
29
  const {
@@ -35,7 +36,7 @@ export const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
35
36
  max = 100,
36
37
  step,
37
38
  disabled,
38
- orientation,
39
+ orientation = 'horizontal',
39
40
  name,
40
41
  onValueChange,
41
42
  onValueCommit,
@@ -51,6 +52,17 @@ export const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
51
52
 
52
53
  const hasMarkLabel = Boolean(marks?.some((mark) => mark.label));
53
54
 
55
+ // gui signals the end of a drag without echoing the value, so keep the last
56
+ // one to satisfy the Chakra-shaped `onValueCommit(value)` contract.
57
+ const latest = React.useRef<Array<number>>(value ?? defaultValue ?? []);
58
+ const handleValueChange = React.useCallback((next: Array<number>) => {
59
+ latest.current = next;
60
+ onValueChange?.(next);
61
+ }, [ onValueChange ]);
62
+ const handleSlideEnd = React.useCallback(() => {
63
+ onValueCommit?.(latest.current);
64
+ }, [ onValueCommit ]);
65
+
54
66
  return (
55
67
  <div className={ cn('flex flex-col gap-1', className) }>
56
68
  { label && !showValue && (
@@ -69,8 +81,9 @@ export const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
69
81
  </div>
70
82
  ) }
71
83
 
72
- <RadixSlider.Root
73
- ref={ ref }
84
+ <GuiSlider
85
+ ref={ ref as never }
86
+ unstyled
74
87
  className={ cn(
75
88
  'relative flex w-full touch-none select-none items-center',
76
89
  hasMarkLabel && 'mb-6',
@@ -83,23 +96,26 @@ export const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
83
96
  disabled={ disabled }
84
97
  orientation={ orientation }
85
98
  name={ name }
86
- onValueChange={ onValueChange }
87
- onValueCommit={ onValueCommit }
99
+ onValueChange={ handleValueChange }
100
+ onSlideEnd={ handleSlideEnd }
88
101
  >
89
- <RadixSlider.Track
90
- className={ cn(
91
- 'relative grow rounded-full bg-border-divider',
92
- `h-[${ TRACK_HEIGHT }px]`,
93
- ) }
102
+ <GuiSlider.Track
103
+ unstyled
104
+ className="relative grow h-1.5 rounded-full bg-border-divider"
94
105
  >
95
- <RadixSlider.Range className="absolute h-full rounded-full bg-link-primary"/>
96
- </RadixSlider.Track>
106
+ <GuiSlider.TrackActive
107
+ unstyled
108
+ className="absolute h-full rounded-full bg-link-primary"
109
+ />
110
+ </GuiSlider.Track>
97
111
 
98
112
  { resolvedValue?.map((_, index) => (
99
- <RadixSlider.Thumb
113
+ <GuiSlider.Thumb
100
114
  key={ index }
115
+ index={ index }
116
+ unstyled
101
117
  className={ cn(
102
- `block h-[${ THUMB_SIZE }px] w-[${ THUMB_SIZE }px]`,
118
+ 'block h-5 w-5',
103
119
  'rounded-full border-2 border-link-primary bg-white',
104
120
  'shadow-sm transition-colors',
105
121
  'hover:border-link-primary-hover',
@@ -108,7 +124,7 @@ export const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
108
124
  ) }
109
125
  />
110
126
  )) }
111
- </RadixSlider.Root>
127
+ </GuiSlider>
112
128
 
113
129
  <SliderMarks marks={ marks } min={ min } max={ max }/>
114
130
  </div>
@@ -207,9 +207,10 @@ function LegRow({ leg, index, strikes, expirations, removable, onChange, onRemov
207
207
  type="button"
208
208
  className={cn(
209
209
  'px-2 py-0.5 rounded text-xs font-medium transition-colors',
210
+ // Call vs put is a rank difference, not a hue difference.
210
211
  leg.type === 'call'
211
- ? 'bg-blue-900/40 text-blue-400'
212
- : 'bg-orange-900/40 text-orange-400',
212
+ ? 'bg-[var(--secondary)] text-[var(--foreground)]'
213
+ : 'bg-[var(--muted)] text-[var(--muted-foreground)]',
213
214
  )}
214
215
  onClick={() => update({ type: leg.type === 'call' ? 'put' : 'call' })}
215
216
  >
package/src/switch.tsx CHANGED
@@ -1,5 +1,4 @@
1
- import * as RadixSwitch from '@radix-ui/react-switch';
2
- // chakra() HOC removed — pure Radix + Tailwind
1
+ import { Switch as GuiSwitch } from '@hanzo/gui';
3
2
  import * as React from 'react';
4
3
 
5
4
  import { cn } from './utils';
@@ -7,20 +6,23 @@ import { cn } from './utils';
7
6
  const NOOP = () => { /* noop */ };
8
7
 
9
8
  // ─── Size mappings ────────────────────────────────────────────────────
9
+ // Paint and box size only. Thumb travel is NOT a hardcoded translate any more:
10
+ // gui measures the track and the knob and moves the knob by the difference, so
11
+ // the switch stays correct at any size and on native.
10
12
  const SIZE_CLASSES = {
11
13
  sm: {
12
14
  root: 'h-4 w-7',
13
- thumb: 'h-3 w-3 data-[state=checked]:translate-x-3',
15
+ thumb: 'h-3 w-3',
14
16
  label: 'text-xs',
15
17
  },
16
18
  md: {
17
19
  root: 'h-5 w-9',
18
- thumb: 'h-4 w-4 data-[state=checked]:translate-x-4',
20
+ thumb: 'h-4 w-4',
19
21
  label: 'text-sm',
20
22
  },
21
23
  lg: {
22
24
  root: 'h-6 w-11',
23
- thumb: 'h-5 w-5 data-[state=checked]:translate-x-5',
25
+ thumb: 'h-5 w-5',
24
26
  label: 'text-base',
25
27
  },
26
28
  } as const;
@@ -72,11 +74,8 @@ const SwitchBase = React.forwardRef<HTMLInputElement, SwitchProps>(
72
74
  setInternalChecked(nextChecked);
73
75
  }
74
76
 
75
- if (onCheckedChange) {
76
- // Support both Chakra-style ({ checked }) and Radix-style (checked) signatures.
77
- // We call with a single object arg; if the consumer ignores it, that's fine.
78
- (onCheckedChange as (details: { checked: boolean }) => void)({ checked: nextChecked });
79
- }
77
+ // Chakra-shaped callback ({ checked }) — the contract consumers already use.
78
+ onCheckedChange?.({ checked: nextChecked });
80
79
  },
81
80
  [ isControlled, onCheckedChange ],
82
81
  );
@@ -97,7 +96,8 @@ const SwitchBase = React.forwardRef<HTMLInputElement, SwitchProps>(
97
96
  data-disabled={ disabled || undefined }
98
97
  { ...rest }
99
98
  >
100
- <RadixSwitch.Root
99
+ <GuiSwitch
100
+ unstyled
101
101
  checked={ checkedState }
102
102
  onCheckedChange={ handleCheckedChange }
103
103
  disabled={ disabled }
@@ -115,11 +115,12 @@ const SwitchBase = React.forwardRef<HTMLInputElement, SwitchProps>(
115
115
  { checkedState ? trackLabel.on : trackLabel.off }
116
116
  </span>
117
117
  ) }
118
- <RadixSwitch.Thumb
118
+ <GuiSwitch.Thumb
119
+ unstyled
120
+ data-thumb
121
+ transition="quicker"
119
122
  className={ cn(
120
123
  'block rounded-full bg-white shadow-sm',
121
- 'transition-transform duration-200',
122
- 'translate-x-0.5',
123
124
  sizeClasses.thumb,
124
125
  ) }
125
126
  >
@@ -128,8 +129,8 @@ const SwitchBase = React.forwardRef<HTMLInputElement, SwitchProps>(
128
129
  { checkedState ? thumbLabel.on : thumbLabel.off }
129
130
  </span>
130
131
  ) }
131
- </RadixSwitch.Thumb>
132
- </RadixSwitch.Root>
132
+ </GuiSwitch.Thumb>
133
+ </GuiSwitch>
133
134
  { /* Hidden native input for form compat and ref forwarding */ }
134
135
  <input
135
136
  ref={ ref }
package/src/textarea.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { TextArea as GuiTextArea } from '@hanzo/gui';
1
2
  import React from 'react';
2
3
 
3
4
  import { cn } from './utils';
@@ -34,15 +35,28 @@ type TextareaVariant = 'outline' | 'filled' | 'unstyled';
34
35
  export interface TextareaProps extends Omit<React.ComponentPropsWithRef<'textarea'>, 'size'> {
35
36
  readonly size?: TextareaSize;
36
37
  readonly variant?: TextareaVariant;
38
+ /** Tamagui / React-Native convention — bridged to native `onChange` (see Input). */
39
+ readonly onChangeText?: (text: string) => void;
37
40
  }
38
41
 
42
+ // gui's TextArea — multiline TextInput on native, `<textarea>` on web — wearing
43
+ // the Lux size/variant classes.
39
44
  export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
40
- ({ size = 'md', variant = 'outline', className, ...rest }, ref) => {
45
+ ({ size = 'md', variant = 'outline', className, onChange, onChangeText, ...rest }, ref) => {
46
+ const handleChange = React.useCallback(
47
+ (event: React.ChangeEvent<HTMLTextAreaElement>): void => {
48
+ onChange?.(event);
49
+ onChangeText?.(event.target.value);
50
+ },
51
+ [ onChange, onChangeText ],
52
+ );
41
53
  return (
42
- <textarea
43
- ref={ ref }
54
+ <GuiTextArea
55
+ ref={ ref as never }
56
+ unstyled
44
57
  className={ cn(TEXTAREA_BASE, TEXTAREA_SIZE_CLASSES[size], TEXTAREA_VARIANT_CLASSES[variant], className) }
45
- { ...rest }
58
+ { ...(rest as object) }
59
+ onChange={ handleChange as never }
46
60
  />
47
61
  );
48
62
  },
package/src/tooltip.tsx CHANGED
@@ -1,36 +1,17 @@
1
1
  import {
2
2
  Tooltip as GuiTooltip,
3
3
  TooltipGroup as GuiTooltipGroup,
4
- } from '@hanzogui/tooltip';
4
+ } from '@hanzo/gui';
5
5
  import { useClickAway } from '@uidotdev/usehooks';
6
6
  import * as React from 'react';
7
7
 
8
+ import { useIsTouch } from './use-narrow';
8
9
  import { cn } from './utils';
9
10
 
10
- // Inline useIsMobile hook (matches lg breakpoint = 1024px)
11
- const LG_BREAKPOINT = 1024;
12
-
13
- function useIsMobile(): boolean {
14
- const [ isMobile, setIsMobile ] = React.useState(() => {
15
- if (typeof window === 'undefined') return false;
16
- return window.innerWidth < LG_BREAKPOINT;
17
- });
18
-
19
- React.useEffect(() => {
20
- const mql = window.matchMedia(`(max-width: ${ LG_BREAKPOINT - 1 }px)`);
21
- const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
22
- setIsMobile(mql.matches);
23
- mql.addEventListener('change', handler);
24
- return () => mql.removeEventListener('change', handler);
25
- }, []);
26
-
27
- return isMobile;
28
- }
29
-
30
11
  /** Map placement string to @hanzogui Popper placement prop. */
31
12
  function mapPlacement(p: string | undefined): string | undefined {
32
13
  if (!p) return undefined;
33
- // @hanzogui/popper uses same placement strings as floating-ui
14
+ // @hanzo/gui uses same placement strings as floating-ui
34
15
  return p;
35
16
  }
36
17
 
@@ -86,7 +67,7 @@ export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
86
67
  const [ open, setOpen ] = React.useState<boolean>(defaultOpen);
87
68
  const timeoutRef = React.useRef<number | null>(null);
88
69
 
89
- const isMobile = useIsMobile();
70
+ const isMobile = useIsTouch();
90
71
 
91
72
  const handleOpenChange = React.useCallback((nextOpen: boolean) => {
92
73
  setOpen(nextOpen);
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ // ONE viewport hook for the whole design system.
6
+ //
7
+ // Three components used to carry their own copy of the same `matchMedia`
8
+ // dance at three different breakpoints, so "is this phone-sized?" had three
9
+ // answers. It has one now, and the breakpoints are named values rather than
10
+ // magic numbers buried in a component.
11
+ //
12
+ // SSR-safe by construction: the first render is always `false` on both the
13
+ // server and the client (there is no viewport during prerender, and guessing
14
+ // one produces a hydration mismatch), then the real answer arrives in an
15
+ // effect before paint.
16
+
17
+ /** Below this the UI is a phone: menus become sheets, drawers drag. */
18
+ export const NARROW_PX = 640;
19
+
20
+ /** Below this the pointer is a finger: hover-only affordances are suppressed. */
21
+ export const TOUCH_PX = 1024;
22
+
23
+ /** True while the viewport is narrower than `px`. */
24
+ export function useBelow(px: number): boolean {
25
+ const [ below, setBelow ] = React.useState(false);
26
+ React.useEffect(() => {
27
+ const mql = window.matchMedia(`(max-width: ${ px - 1 }px)`);
28
+ const apply = () => setBelow(mql.matches);
29
+ apply();
30
+ mql.addEventListener('change', apply);
31
+ return () => mql.removeEventListener('change', apply);
32
+ }, [ px ]);
33
+ return below;
34
+ }
35
+
36
+ /** Phone-sized: present menus as sheets. */
37
+ export const useIsNarrow = (): boolean => useBelow(NARROW_PX);
38
+
39
+ /** Touch-sized: no hover, so hover-only affordances must not be the only path. */
40
+ export const useIsTouch = (): boolean => useBelow(TOUCH_PX);
package/src/vite.ts ADDED
@@ -0,0 +1,78 @@
1
+ // The bundler wiring the gui engine needs on web, as ONE plugin.
2
+ //
3
+ // @hanzo/gui is authored against React Native primitives, so a web bundle has to
4
+ // alias `react-native` → `react-native-web`, pre-bundle the CJS-only pieces, keep
5
+ // exactly one physical copy of React and of the engine, and define the platform
6
+ // flags the engine reads at module scope. Every Lux surface needs the identical
7
+ // six-part incantation, and every surface that hand-rolled it got a different
8
+ // subset — which is how you end up debugging `createContext of undefined` in one
9
+ // app and a duplicate-React theme context in another.
10
+ //
11
+ // import { luxUi } from '@luxfi/ui/vite'
12
+ // export default defineConfig({ plugins: [react(), luxUi()] })
13
+ //
14
+ // Node-only (it is a build-time module) and dependency-free: it returns a plain
15
+ // Vite plugin object, so it does not force `vite` into anyone's runtime graph.
16
+
17
+ interface LuxUiViteOptions {
18
+ /**
19
+ * Extra packages to keep to a single physical copy. React, the gui engine and
20
+ * TanStack Query are always deduped.
21
+ */
22
+ readonly dedupe?: ReadonlyArray<string>;
23
+ /**
24
+ * Extra packages to pre-bundle. The engine, react-native-web and the CJS-only
25
+ * colour helper are always included.
26
+ */
27
+ readonly include?: ReadonlyArray<string>;
28
+ }
29
+
30
+ /** Minimal structural shape of a Vite plugin — avoids a hard `vite` type dep. */
31
+ interface LuxUiVitePlugin {
32
+ readonly name: string;
33
+ readonly config: () => {
34
+ define: Record<string, string>;
35
+ resolve: { alias: Record<string, string>; dedupe: Array<string> };
36
+ optimizeDeps: { include: Array<string> };
37
+ };
38
+ }
39
+
40
+ const DEDUPE = [
41
+ 'react',
42
+ 'react-dom',
43
+ '@tanstack/react-query',
44
+ '@hanzo/gui',
45
+ '@hanzogui/core',
46
+ '@hanzogui/web',
47
+ ];
48
+
49
+ const INCLUDE = [
50
+ 'react-native-web',
51
+ '@react-native/normalize-color',
52
+ '@hanzo/gui',
53
+ '@hanzogui/core',
54
+ '@luxfi/ui',
55
+ ];
56
+
57
+ export function luxUi(options: LuxUiViteOptions = {}): LuxUiVitePlugin {
58
+ return {
59
+ name: '@luxfi/ui',
60
+ config: () => ({
61
+ // 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
+ resolve: {
68
+ alias: { 'react-native': 'react-native-web' },
69
+ dedupe: [ ...new Set([ ...DEDUPE, ...(options.dedupe ?? []) ]) ],
70
+ },
71
+ optimizeDeps: {
72
+ include: [ ...new Set([ ...INCLUDE, ...(options.include ?? []) ]) ],
73
+ },
74
+ }),
75
+ };
76
+ }
77
+
78
+ export type { LuxUiViteOptions };
@@ -0,0 +1,240 @@
1
+ // White-label resolution — BY HOSTNAME, never per deployment.
2
+ //
3
+ // One image, many brands. Brand, gui theme row, IAM client id and OIDC issuer
4
+ // are all a pure function of the Host header, so the same container serves
5
+ // lux.cloud, zoo.network, hanzo.ai and pars.id without a rebuild, an env var or
6
+ // a per-deployment override. Nothing here reads process.env.
7
+ //
8
+ // This module is VALUES ONLY (no React, no DOM) so it resolves identically in
9
+ // three places that must agree:
10
+ // - the Next/SSR server, from `req.headers.host`
11
+ // - the browser, from `location.host`
12
+ // - an edge/middleware rewrite, from the incoming URL
13
+ //
14
+ // Logos, SEO copy and marketing palettes live in @luxfi/brand. This is the
15
+ // runtime identity the UI layer needs and nothing more.
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Branding = VALUE, not place.
19
+ // The ONE spot brand hex is allowed to live (project rule: never hardcode hex
20
+ // in components). It lives HERE, beside the identity table it colours, rather
21
+ // than in ./lux.config — that module builds the gui engine, and an identity
22
+ // value that drags a Tamagui config behind it cannot be read by a server
23
+ // component, a route handler or the `@luxfi/ui/iam` client. lux.config imports
24
+ // these; nothing imports lux.config to get them.
25
+ // ---------------------------------------------------------------------------
26
+ export const LUX_BRAND = {
27
+ lux: '#7000FF',
28
+ zoo: '#6C5EFB',
29
+ hanzo: '#EA580C',
30
+ pars: '#1C3879',
31
+ // bootno.de is its own white label with its own IdP (id.bootno.de) — accent
32
+ // taken from the bootnode console's own brand default, not invented here.
33
+ bootnode: '#3B82F6',
34
+ } as const;
35
+
36
+ export const PARS_GOLD = '#C8A45C';
37
+
38
+ export type LuxBrand = keyof typeof LUX_BRAND;
39
+
40
+ export type Org = LuxBrand;
41
+
42
+ export interface OrgIdentity {
43
+ /** Org id — also the gui child-theme suffix (`dark_lux`, `dark_zoo`, …). */
44
+ readonly org: Org;
45
+ /** Display name for chrome (header, title, sign-in copy). */
46
+ readonly name: string;
47
+ /** Canonical apex domain. */
48
+ readonly domain: string;
49
+ /** IAM/OIDC host. The issuer is `https://` + this. */
50
+ readonly iamDomain: string;
51
+ /** Brand accent hex — the same value the gui child-theme row is built from. */
52
+ readonly accent: string;
53
+ /** Foreground used on top of the accent. */
54
+ readonly accentForeground: string;
55
+ }
56
+
57
+ // The ONE table. Every hostname in the fleet resolves into one of these rows.
58
+ const ORGS: Readonly<Record<Org, OrgIdentity>> = {
59
+ lux: {
60
+ org: 'lux',
61
+ name: 'Lux',
62
+ domain: 'lux.network',
63
+ iamDomain: 'lux.id',
64
+ accent: LUX_BRAND.lux,
65
+ accentForeground: '#FFFFFF',
66
+ },
67
+ zoo: {
68
+ org: 'zoo',
69
+ name: 'Zoo',
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',
75
+ accent: LUX_BRAND.zoo,
76
+ accentForeground: '#FFFFFF',
77
+ },
78
+ bootnode: {
79
+ org: 'bootnode',
80
+ name: 'Bootnode',
81
+ domain: 'bootno.de',
82
+ iamDomain: 'id.bootno.de',
83
+ accent: LUX_BRAND.lux,
84
+ accentForeground: '#FFFFFF',
85
+ },
86
+ hanzo: {
87
+ org: 'hanzo',
88
+ name: 'Hanzo',
89
+ domain: 'hanzo.ai',
90
+ iamDomain: 'hanzo.id',
91
+ accent: LUX_BRAND.hanzo,
92
+ accentForeground: '#FFFFFF',
93
+ },
94
+ pars: {
95
+ org: 'pars',
96
+ name: 'Pars',
97
+ domain: 'pars.id',
98
+ iamDomain: 'pars.id',
99
+ accent: LUX_BRAND.pars,
100
+ accentForeground: PARS_GOLD,
101
+ },
102
+ };
103
+
104
+ // Registrable domains that belong to each org. Matched against the hostname
105
+ // SUFFIX on a label boundary, so `mpc.lux.cloud` and `lux.cloud` both hit `lux`
106
+ // while `notlux.cloud` does not.
107
+ //
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.
112
+ const DOMAIN_ORG: ReadonlyArray<readonly [string, Org, string?]> = [
113
+ [ 'lux.network', 'lux' ],
114
+ [ 'lux.cloud', 'lux' ],
115
+ [ 'lux.id', 'lux' ],
116
+ [ 'lux.finance', 'lux' ],
117
+ [ 'zoo.ngo', 'zoo' ],
118
+ [ 'zoo.network', 'zoo' ],
119
+ [ 'zoo.cloud', 'zoo' ],
120
+ [ 'zoo.id', 'zoo' ],
121
+ [ 'hanzo.ai', 'hanzo' ],
122
+ [ 'hanzo.cloud', 'hanzo' ],
123
+ [ 'hanzo.id', 'hanzo' ],
124
+ [ 'pars.id', 'pars' ],
125
+ [ 'parsdao.org', 'pars' ],
126
+ [ 'bootno.de', 'bootnode', 'platform' ],
127
+ ];
128
+
129
+ export const DEFAULT_ORG: Org = 'lux';
130
+
131
+ export interface WhiteLabel extends OrgIdentity {
132
+ /** Hostname the identity was resolved from, port stripped, lowercased. */
133
+ readonly host: string;
134
+ /** App slug taken from the hostname — `mpc.lux.cloud` → `mpc`. */
135
+ readonly app: string;
136
+ /** gui child-theme row to hand GuiProvider — `dark_lux`, `dark_zoo`, … */
137
+ readonly theme: string;
138
+ /** OIDC issuer — `https://lux.id`. */
139
+ readonly issuer: string;
140
+ /** IAM client id, `<org>-<app>` per the fleet naming scheme — `lux-mpc`. */
141
+ readonly clientId: string;
142
+ }
143
+
144
+ /** Strip the port and any trailing dot, lowercase. `LUX.cloud:3000` → `lux.cloud`. */
145
+ function normalizeHost(host: string): string {
146
+ const noPort = host.trim().toLowerCase().split(':')[0] ?? '';
147
+ return noPort.replace(/\.$/, '');
148
+ }
149
+
150
+ /** true when `host` is `domain` or a subdomain of it (label boundary respected). */
151
+ function isUnder(host: string, domain: string): boolean {
152
+ return host === domain || host.endsWith(`.${ domain }`);
153
+ }
154
+
155
+ /** The registrable domain this host sits under, or undefined for unknown hosts. */
156
+ function matchDomain(host: string): readonly [string, Org, string?] | undefined {
157
+ // Longest domain first so `zoo.network` wins over a hypothetical `network`.
158
+ let best: readonly [string, Org, string?] | undefined;
159
+ for (const entry of DOMAIN_ORG) {
160
+ if (isUnder(host, entry[0]) && (!best || entry[0].length > best[0].length)) {
161
+ best = entry;
162
+ }
163
+ }
164
+ return best;
165
+ }
166
+
167
+ /**
168
+ * App slug for the IAM client id.
169
+ *
170
+ * `mpc.lux.cloud` → `mpc` (leftmost label)
171
+ * `lux.cloud` → `cloud` (apex: the service name, not the org)
172
+ * `www.lux.network`→ `network` (www is chrome, not an app)
173
+ * `localhost` → `local`
174
+ */
175
+ function appSlug(host: string, domain: string, apexApp?: string): string {
176
+ const sub = host === domain ? '' : host.slice(0, -(domain.length + 1));
177
+ const leftmost = sub.split('.')[0] ?? '';
178
+ 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;
182
+ const label = domain.split('.')[0] ?? '';
183
+ const tld = domain.split('.').slice(1).join('.');
184
+ // `lux.network` → `network`, `lux.cloud` → `cloud`, `pars.id` → `id`.
185
+ return tld.split('.')[0] || label;
186
+ }
187
+
188
+ /** Loopback / LAN / *.local hosts a developer actually types. */
189
+ function isLocal(host: string): boolean {
190
+ return (
191
+ host === 'localhost' ||
192
+ host.endsWith('.localhost') ||
193
+ host === '127.0.0.1' ||
194
+ host === '0.0.0.0' ||
195
+ host === '[::1]' ||
196
+ host.endsWith('.local')
197
+ );
198
+ }
199
+
200
+ /**
201
+ * Resolve the full white-label identity from a Host header value.
202
+ *
203
+ * Pure and total: an unknown or missing host falls back to the Lux row rather
204
+ * than throwing, so a preview URL or a health probe never blanks the UI.
205
+ *
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
209
+ */
210
+ export function resolveWhiteLabel(host?: string | null): WhiteLabel {
211
+ const h = normalizeHost(host ?? '');
212
+ const match = h ? matchDomain(h) : undefined;
213
+ const org = match ? match[1] : DEFAULT_ORG;
214
+ const identity = ORGS[org];
215
+ const app = match ? appSlug(h, match[0], match[2]) : (h && isLocal(h) ? 'local' : 'app');
216
+ return {
217
+ ...identity,
218
+ host: h,
219
+ app,
220
+ theme: `dark_${ org }`,
221
+ issuer: `https://${ identity.iamDomain }`,
222
+ clientId: `${ org }-${ app }`,
223
+ };
224
+ }
225
+
226
+ /**
227
+ * The current host in a browser, or '' during SSR / prerender.
228
+ * Server code must pass `req.headers.host` to resolveWhiteLabel explicitly —
229
+ * there is no ambient request here, and guessing would bake in a brand.
230
+ */
231
+ export function currentHost(): string {
232
+ return typeof window === 'undefined' ? '' : window.location.host;
233
+ }
234
+
235
+ /** Identity for the org id itself, for the rare surface that is not host-routed. */
236
+ export function orgIdentity(org: Org): OrgIdentity {
237
+ return ORGS[org];
238
+ }
239
+
240
+ export { ORGS as orgs };