@hanzo/ui 8.0.5 → 8.0.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.
Files changed (39) hide show
  1. package/package.json +5 -1
  2. package/src/product/AppHeader.tsx +34 -26
  3. package/src/product/ComboBox.tsx +49 -69
  4. package/src/product/SelectMenu.tsx +23 -37
  5. package/src/product/ThemeToggle.tsx +121 -11
  6. package/src/product/ThemeToggleNext.tsx +27 -0
  7. package/src/product/index.ts +8 -0
  8. package/src/product/menu/ContextMenu.tsx +114 -0
  9. package/src/product/menu/DropdownMenu.tsx +76 -0
  10. package/src/product/menu/index.ts +15 -0
  11. package/src/product/menu/items.tsx +230 -0
  12. package/src/product/menu/portal-theme.tsx +33 -0
  13. package/src/product/menu/roving.ts +34 -0
  14. package/src/product/surfaces.data.ts +42 -0
  15. package/types/product/AppHeader.d.ts +5 -11
  16. package/types/product/AppHeader.d.ts.map +1 -1
  17. package/types/product/ComboBox.d.ts.map +1 -1
  18. package/types/product/SelectMenu.d.ts +8 -5
  19. package/types/product/SelectMenu.d.ts.map +1 -1
  20. package/types/product/ThemeToggle.d.ts +21 -1
  21. package/types/product/ThemeToggle.d.ts.map +1 -1
  22. package/types/product/ThemeToggleNext.d.ts +5 -0
  23. package/types/product/ThemeToggleNext.d.ts.map +1 -0
  24. package/types/product/index.d.ts +3 -0
  25. package/types/product/index.d.ts.map +1 -1
  26. package/types/product/menu/ContextMenu.d.ts +24 -0
  27. package/types/product/menu/ContextMenu.d.ts.map +1 -0
  28. package/types/product/menu/DropdownMenu.d.ts +34 -0
  29. package/types/product/menu/DropdownMenu.d.ts.map +1 -0
  30. package/types/product/menu/index.d.ts +5 -0
  31. package/types/product/menu/index.d.ts.map +1 -0
  32. package/types/product/menu/items.d.ts +69 -0
  33. package/types/product/menu/items.d.ts.map +1 -0
  34. package/types/product/menu/portal-theme.d.ts +29 -0
  35. package/types/product/menu/portal-theme.d.ts.map +1 -0
  36. package/types/product/menu/roving.d.ts +9 -0
  37. package/types/product/menu/roving.d.ts.map +1 -0
  38. package/types/product/surfaces.data.d.ts +17 -0
  39. package/types/product/surfaces.data.d.ts.map +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/ui",
3
- "version": "8.0.5",
3
+ "version": "8.0.7",
4
4
  "type": "module",
5
5
  "description": "Hanzo UI \u2014 the one cross-platform component library on @hanzo/gui. The product/app layer (charts, metrics, page headers, status tags, rich empty states, combobox, slide-over, toasts, drag-reorder, labeled field rows, provider/product marks) + the metadata-driven record layer (@hanzo/data: RecordsView, DataTable, board, typed field editors) + the calm dark-first tokens and motion. Presentational, host-agnostic (data/effects injected), clean-room. Web + native (iOS) + desktop.",
6
6
  "exports": {
@@ -78,6 +78,7 @@
78
78
  "@hanzo/gui": ">=7.2.2",
79
79
  "@hanzo/ui-shadcn": ">=5.7.0",
80
80
  "@hanzo/usage": ">=0.1.0",
81
+ "@hanzogui/next-theme": ">=7.3.0",
81
82
  "react": ">=19"
82
83
  },
83
84
  "peerDependenciesMeta": {
@@ -95,6 +96,9 @@
95
96
  },
96
97
  "@hanzo/usage": {
97
98
  "optional": true
99
+ },
100
+ "@hanzogui/next-theme": {
101
+ "optional": true
98
102
  }
99
103
  },
100
104
  "devDependencies": {
@@ -13,22 +13,22 @@
13
13
  */
14
14
  import { useState, type ReactNode } from 'react'
15
15
  import { Button, Popover, Separator, Text, XStack, YStack } from '@hanzo/gui'
16
- import { CreditCard, Grip, LogOut, Settings2, UserRound } from '@hanzogui/lucide-icons-2'
16
+ import { AppWindow, Bot, CreditCard, Grip, LayoutGrid, LogOut, MessageCircle, Settings2, Sparkles, UserRound, Users } from '@hanzogui/lucide-icons-2'
17
17
 
18
18
  import { BrandMark } from './BrandMark'
19
19
  import { ThemeToggle } from './ThemeToggle'
20
+ import { otherSurfaces, type Surface, type SurfaceId } from './surfaces.data'
20
21
 
21
- /** One Hanzo surface the app switcher offers. */
22
- export type Surface = { id: string; label: string; href: string; hint?: string }
23
-
24
- /** The five Hanzo surfaces (cloud opens the console). */
25
- export const SURFACES: Surface[] = [
26
- { id: 'ai', label: 'Hanzo AI', href: 'https://hanzo.ai', hint: 'hanzo.ai' },
27
- { id: 'cloud', label: 'Cloud', href: 'https://console.hanzo.ai', hint: 'console' },
28
- { id: 'console', label: 'Console', href: 'https://console.hanzo.ai', hint: 'console.hanzo.ai' },
29
- { id: 'app', label: 'App', href: 'https://hanzo.app', hint: 'hanzo.app' },
30
- { id: 'team', label: 'Team', href: 'https://hanzo.team', hint: 'hanzo.team' },
31
- ]
22
+ /** The per-surface glyph keyed by `Surface.id`, so each surface reads distinctly. */
23
+ const SURFACE_ICON = {
24
+ ai: Sparkles,
25
+ console: LayoutGrid,
26
+ app: AppWindow,
27
+ chat: MessageCircle,
28
+ bot: Bot,
29
+ team: Users,
30
+ billing: CreditCard,
31
+ } as const satisfies Record<SurfaceId, unknown>
32
32
 
33
33
  const openHref = (href: string) => {
34
34
  if (typeof window !== 'undefined') window.open(href, '_blank', 'noopener')
@@ -56,7 +56,9 @@ export type AppHeaderProps = {
56
56
  org?: ReactNode
57
57
  /** Free slot between the org slot and the right cluster. */
58
58
  children?: ReactNode
59
- /** App-switcher surfaces; [] hides the switcher. */
59
+ /** The surface this header renders on — omitted from the switcher (no self-link). */
60
+ current?: SurfaceId
61
+ /** App-switcher surfaces; defaults to every surface but `current`. [] hides it. */
60
62
  surfaces?: Surface[]
61
63
  /** Open a surface — default `window.open` (new tab). */
62
64
  open?: (surface: Surface) => void
@@ -78,7 +80,8 @@ export function AppHeader({
78
80
  onBrand,
79
81
  org,
80
82
  children,
81
- surfaces = SURFACES,
83
+ current,
84
+ surfaces,
82
85
  open,
83
86
  user,
84
87
  menu,
@@ -90,6 +93,7 @@ export function AppHeader({
90
93
  }: AppHeaderProps) {
91
94
  const [appsOpen, setAppsOpen] = useState(false)
92
95
  const [menuOpen, setMenuOpen] = useState(false)
96
+ const items = surfaces ?? otherSurfaces(current)
93
97
  const launch = (s: Surface) => {
94
98
  setAppsOpen(false)
95
99
  if (open) open(s)
@@ -112,25 +116,29 @@ export function AppHeader({
112
116
  {children}
113
117
  </XStack>
114
118
 
115
- {surfaces.length > 0 ? (
119
+ {items.length > 0 ? (
116
120
  <Popover open={appsOpen} onOpenChange={setAppsOpen} placement="bottom-end">
117
121
  <Popover.Trigger asChild>
118
122
  <Button size="$2" chromeless icon={<Grip size={16} />} aria-label="Apps" />
119
123
  </Popover.Trigger>
120
124
  <Popover.Content bordered elevate p="$2" width={230} bg="$color2" borderColor="$borderColor">
121
125
  <YStack gap="$1">
122
- {surfaces.map((s) => (
123
- <XStack key={s.id} onPress={() => launch(s)} cursor="pointer" items="center" gap="$2.5" px="$2" py="$2" rounded="$3" hoverStyle={{ bg: '$color5' }}>
124
- <Text flex={1} fontSize="$2" fontWeight="600" color="$color12">
125
- {s.label}
126
- </Text>
127
- {s.hint ? (
128
- <Text fontSize="$1" color="$color10">
129
- {s.hint}
126
+ {items.map((s) => {
127
+ const Icon = SURFACE_ICON[s.id]
128
+ return (
129
+ <XStack key={s.id} onPress={() => launch(s)} cursor="pointer" items="center" gap="$2.5" px="$2" py="$2" rounded="$3" hoverStyle={{ bg: '$color5' }}>
130
+ <Icon size={16} />
131
+ <Text flex={1} fontSize="$2" fontWeight="600" color="$color12">
132
+ {s.label}
130
133
  </Text>
131
- ) : null}
132
- </XStack>
133
- ))}
134
+ {s.hint ? (
135
+ <Text fontSize="$1" color="$color10">
136
+ {s.hint}
137
+ </Text>
138
+ ) : null}
139
+ </XStack>
140
+ )
141
+ })}
134
142
  </YStack>
135
143
  </Popover.Content>
136
144
  </Popover>
@@ -1,22 +1,23 @@
1
1
  'use client'
2
2
 
3
3
  /**
4
- * ComboBox — a typeable select: a text input the user can type any value into,
5
- * PLUS a popover of LIVE options (filtered by what's typed) they can pick from.
6
- * The value is always exactly the input text, so a custom id is inherently
7
- * supported selecting an option just fills the input. This is the ONE way the
8
- * console offers "pick from a live list OR type your own" (the model field, the
9
- * tool field). Prop-driven + self-contained (options/loading/error injected by the
10
- * caller), so it is orthogonal to the data source and lifts into `@hanzo/ui`.
4
+ * ComboBox — a typeable select: a text input the user can type any value into, PLUS a
5
+ * menu of LIVE options (filtered by what's typed) they can pick from. The value is
6
+ * always exactly the input text, so a custom id is inherently supported. Prop-driven +
7
+ * self-contained (options/loading/error injected by the caller).
11
8
  *
12
- * Idiom: the same @hanzo/gui Popover shell as OrgSwitcher/SelectMenu (bordered,
13
- * elevate, `$color2`). Options render in a portal above the DetailPane SlideOver.
9
+ * Uses the ONE shared menu spec (MenuPanel + MenuItemView) so options look identical to
10
+ * every other menu, and the SAME portal-theme fix (PortalTheme) so the list renders
11
+ * correctly through the portal above a SlideOver and under a nested `<Theme>`.
14
12
  */
15
13
  import { useMemo, useState } from 'react'
16
- import { Button, Input, Popover, Spinner, Text, XStack, YStack } from '@hanzo/gui'
17
- import { Check, ChevronDown, RefreshCw } from '@hanzogui/lucide-icons-2'
14
+ import { Button, Input, Popover, Spinner, Text, XStack } from '@hanzo/gui'
15
+ import { ChevronDown, RefreshCw } from '@hanzogui/lucide-icons-2'
18
16
 
19
17
  import { filterOptions, type ComboOption } from './combobox/filter'
18
+ import { MenuItemView, MenuPanel } from './menu/items'
19
+ import { PortalTheme, useThemeName } from './menu/portal-theme'
20
+ import { menuKeyDown } from './menu/roving'
20
21
 
21
22
  export type { ComboOption } from './combobox/filter'
22
23
 
@@ -49,6 +50,7 @@ export function ComboBox({
49
50
  minWidth?: number
50
51
  }) {
51
52
  const [open, setOpen] = useState(false)
53
+ const themeName = useThemeName()
52
54
  const filtered = useMemo(() => filterOptions(options, value), [options, value])
53
55
 
54
56
  const pick = (v: string) => {
@@ -62,7 +64,7 @@ export function ComboBox({
62
64
  <Input
63
65
  flex={1}
64
66
  value={value}
65
- onChangeText={(v) => {
67
+ onChangeText={(v: string) => {
66
68
  onChange(v)
67
69
  if (!open) setOpen(true)
68
70
  }}
@@ -83,65 +85,43 @@ export function ComboBox({
83
85
  </Popover.Trigger>
84
86
  </XStack>
85
87
 
86
- <Popover.Content bordered elevate p="$1.5" minW={minWidth} bg="$color2" borderColor="$borderColor">
87
- <YStack gap="$0.5" minW={minWidth} maxH={300} overflow="scroll">
88
- {loading ? (
89
- <XStack items="center" gap="$2" px="$2.5" py="$2">
90
- <Spinner size="small" color="$color11" />
91
- <Text fontSize="$2" color="$color10">
92
- Loading options…
88
+ <Popover.Content bg="transparent" borderWidth={0} p={0} elevation={0}>
89
+ <PortalTheme name={themeName}>
90
+ <MenuPanel minWidth={minWidth} maxHeight={300} onKeyDown={(e) => menuKeyDown(e, () => setOpen(false))}>
91
+ {loading ? (
92
+ <XStack items="center" gap="$2" px="$2" py="$2">
93
+ <Spinner size="small" color="$color11" />
94
+ <Text fontSize="$2" color="$color10">
95
+ Loading options…
96
+ </Text>
97
+ </XStack>
98
+ ) : error ? (
99
+ <XStack items="center" gap="$2" px="$2" py="$2">
100
+ <Text fontSize="$2" color="$color10" flex={1} numberOfLines={2}>
101
+ {error}
102
+ </Text>
103
+ {onRetry ? (
104
+ <Button size="$1" chromeless icon={<RefreshCw size={12} />} onPress={onRetry} aria-label="Retry" />
105
+ ) : null}
106
+ </XStack>
107
+ ) : filtered.length === 0 ? (
108
+ <Text fontSize="$2" color="$color10" px="$2" py="$2">
109
+ {emptyText}
93
110
  </Text>
94
- </XStack>
95
- ) : error ? (
96
- <XStack items="center" gap="$2" px="$2.5" py="$2">
97
- <Text fontSize="$2" color="$color10" flex={1} numberOfLines={2}>
98
- {error}
99
- </Text>
100
- {onRetry ? (
101
- <Button size="$1" chromeless icon={<RefreshCw size={12} />} onPress={onRetry} aria-label="Retry" />
102
- ) : null}
103
- </XStack>
104
- ) : filtered.length === 0 ? (
105
- <Text fontSize="$2" color="$color10" px="$2.5" py="$2">
106
- {emptyText}
107
- </Text>
108
- ) : (
109
- filtered.map((o) => (
110
- <Row key={o.value} option={o} active={o.value === value} onPress={() => pick(o.value)} />
111
- ))
112
- )}
113
- </YStack>
111
+ ) : (
112
+ filtered.map((o) => (
113
+ <MenuItemView
114
+ key={o.value}
115
+ label={o.label ?? o.value}
116
+ description={o.hint}
117
+ selected={o.value === value}
118
+ onSelect={() => pick(o.value)}
119
+ />
120
+ ))
121
+ )}
122
+ </MenuPanel>
123
+ </PortalTheme>
114
124
  </Popover.Content>
115
125
  </Popover>
116
126
  )
117
127
  }
118
-
119
- function Row({ option, active, onPress }: { option: ComboOption; active: boolean; onPress: () => void }) {
120
- return (
121
- <XStack
122
- items="center"
123
- gap="$2"
124
- px="$2.5"
125
- py="$1.5"
126
- rounded="$3"
127
- cursor="pointer"
128
- hoverStyle={{ bg: '$color4' }}
129
- bg={active ? '$color4' : 'transparent'}
130
- onPress={onPress}
131
- >
132
- <XStack width={14} items="center" justify="center">
133
- {active ? <Check size={13} /> : null}
134
- </XStack>
135
- <YStack flex={1} minW={0}>
136
- <Text fontSize="$2" color="$color12" numberOfLines={1}>
137
- {option.label ?? option.value}
138
- </Text>
139
- {option.hint ? (
140
- <Text fontSize="$1" color="$color10" numberOfLines={1}>
141
- {option.hint}
142
- </Text>
143
- ) : null}
144
- </YStack>
145
- </XStack>
146
- )
147
- }
@@ -1,16 +1,22 @@
1
1
  'use client'
2
2
 
3
3
  /**
4
- * SelectMenu — a compact, reusable dropdown select on @hanzo/gui Popover (the
5
- * console's proven dropdown idiom, same as OrgSwitcher/ScopeSwitcher). Renders a
6
- * labelled trigger ("All types", or the chosen option) and a popover list with a
7
- * check on the active option. Prop-driven + self-contained so it lifts into
8
- * `@hanzo/ui`. `value === null` is the "all"/unfiltered state.
4
+ * SelectMenu — a compact, reusable dropdown select on @hanzo/gui Popover. Renders a
5
+ * labelled trigger ("All types", or the chosen option) and a menu list with a check on
6
+ * the active option. Prop-driven + self-contained. `value === null` is the
7
+ * "all"/unfiltered state.
8
+ *
9
+ * Uses the ONE shared menu spec (MenuPanel + MenuItemView) so it is pixel-identical to
10
+ * DropdownMenu/ContextMenu, and the SAME portal-theme fix (PortalTheme) so it renders
11
+ * correctly through the portal under a nested `<Theme>`, light or dark.
9
12
  */
10
13
  import type { ReactElement } from 'react'
11
14
  import { useState } from 'react'
12
- import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
13
- import { ChevronDown, Check } from '@hanzogui/lucide-icons-2'
15
+ import { Button, Popover, Text } from '@hanzo/gui'
16
+ import { ChevronDown } from '@hanzogui/lucide-icons-2'
17
+ import { MenuItemView, MenuPanel } from './menu/items'
18
+ import { PortalTheme, useThemeName } from './menu/portal-theme'
19
+ import { menuKeyDown } from './menu/roving'
14
20
 
15
21
  export type SelectOption<T extends string> = { key: T; label: string }
16
22
 
@@ -32,6 +38,7 @@ export function SelectMenu<T extends string>({
32
38
  minWidth?: number
33
39
  }) {
34
40
  const [open, setOpen] = useState(false)
41
+ const themeName = useThemeName()
35
42
  const active = value === null ? null : options.find((o) => o.key === value) ?? null
36
43
  const triggerLabel = active ? active.label : allLabel
37
44
 
@@ -58,37 +65,16 @@ export function SelectMenu<T extends string>({
58
65
  </Text>
59
66
  </Button>
60
67
  </Popover.Trigger>
61
- <Popover.Content bordered elevate p="$1.5" minW={minWidth} bg="$color2" borderColor="$borderColor">
62
- <YStack gap="$0.5" minW={minWidth} maxH={320} overflow="scroll">
63
- <Row label={allLabel} active={value === null} onPress={() => pick(null)} />
64
- {options.map((o) => (
65
- <Row key={o.key} label={o.label} active={value === o.key} onPress={() => pick(o.key)} />
66
- ))}
67
- </YStack>
68
+ <Popover.Content bg="transparent" borderWidth={0} p={0} elevation={0}>
69
+ <PortalTheme name={themeName}>
70
+ <MenuPanel minWidth={minWidth} maxHeight={320} onKeyDown={(e) => menuKeyDown(e, () => setOpen(false))}>
71
+ <MenuItemView label={allLabel} selected={value === null} onSelect={() => pick(null)} />
72
+ {options.map((o) => (
73
+ <MenuItemView key={o.key} label={o.label} selected={value === o.key} onSelect={() => pick(o.key)} />
74
+ ))}
75
+ </MenuPanel>
76
+ </PortalTheme>
68
77
  </Popover.Content>
69
78
  </Popover>
70
79
  )
71
80
  }
72
-
73
- function Row({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
74
- return (
75
- <XStack
76
- items="center"
77
- gap="$2"
78
- px="$2.5"
79
- py="$1.5"
80
- rounded="$3"
81
- cursor="pointer"
82
- hoverStyle={{ bg: '$color4' }}
83
- bg={active ? '$color4' : 'transparent'}
84
- onPress={onPress}
85
- >
86
- <XStack width={14} items="center" justify="center">
87
- {active ? <Check size={13} /> : null}
88
- </XStack>
89
- <Text fontSize="$2" color="$color12" flex={1} numberOfLines={1}>
90
- {label}
91
- </Text>
92
- </XStack>
93
- )
94
- }
@@ -1,25 +1,135 @@
1
1
  'use client'
2
2
 
3
3
  /**
4
- * Theme toggle — flips the console between dark and light via next-theme. The
5
- * provider already wires `NextThemeProvider` `GuiProvider`, so setting the
6
- * theme here re-themes the whole tree. Shows the action's target icon (a sun in
7
- * dark mode, a moon in light mode).
4
+ * ThemeToggle — flips between dark and light. Framework-agnostic by default so
5
+ * Vite / Tauri / Express hosts can use it with NO Next dependency:
6
+ *
7
+ * Controlled: <ThemeToggle theme={theme} onToggle={setTheme} /> (host owns theme)
8
+ * • Uncontrolled (agnostic): <ThemeToggle onThemeChange={fn} /> (toggles the DOM
9
+ * `.dark` class + persists to localStorage)
10
+ * • Uncontrolled (no props): <ThemeToggle /> (console/Next —
11
+ * falls back to the OPTIONAL @hanzogui/next-theme path;
12
+ * if it cannot load, degrades to the agnostic DOM toggle)
13
+ *
14
+ * Shows the action's target icon (a sun in dark mode, a moon in light mode).
8
15
  */
16
+ import type { ReactNode } from 'react'
17
+ import { Component, Suspense, lazy, useState } from 'react'
9
18
  import { Button } from '@hanzo/gui'
10
- import { useThemeSetting } from '@hanzogui/next-theme'
11
19
  import { Moon, Sun } from '@hanzogui/lucide-icons-2'
12
20
 
13
- export function ThemeToggle() {
14
- const { current, resolvedTheme, set } = useThemeSetting()
15
- const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
21
+ export type ThemeMode = 'light' | 'dark'
22
+
23
+ export type ThemeToggleProps = {
24
+ /** Controlled theme. Pair with `onToggle`/`onThemeChange` to fully control it. */
25
+ theme?: ThemeMode
26
+ /** Seed for uncontrolled mode (default: read from the DOM `.dark` class, else 'dark'). */
27
+ defaultTheme?: ThemeMode
28
+ /** Called with the next theme on toggle (alias of `onThemeChange`). */
29
+ onToggle?: (next: ThemeMode) => void
30
+ /** Called with the next theme on toggle. */
31
+ onThemeChange?: (next: ThemeMode) => void
32
+ /** @hanzo/gui Button size token (default "$2"). */
33
+ size?: string
34
+ /** aria-label override. */
35
+ label?: string
36
+ }
37
+
38
+ const STORAGE_KEY = 'theme'
39
+
40
+ function readDomTheme(): ThemeMode | undefined {
41
+ if (typeof document === 'undefined') return undefined
42
+ const el = document.documentElement
43
+ if (el.classList.contains('dark')) return 'dark'
44
+ if (el.classList.contains('light')) return 'light'
45
+ try {
46
+ const ls = window.localStorage.getItem(STORAGE_KEY)
47
+ if (ls === 'light' || ls === 'dark') return ls
48
+ } catch {
49
+ /* localStorage may be unavailable */
50
+ }
51
+ return undefined
52
+ }
53
+
54
+ function applyDomTheme(next: ThemeMode): void {
55
+ if (typeof document === 'undefined') return
56
+ const el = document.documentElement
57
+ el.classList.toggle('dark', next === 'dark')
58
+ el.classList.toggle('light', next === 'light')
59
+ el.style.colorScheme = next
60
+ try {
61
+ window.localStorage.setItem(STORAGE_KEY, next)
62
+ } catch {
63
+ /* ignore */
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The framework-agnostic toggle button. Controlled when `theme` is provided; otherwise
69
+ * self-managed via the DOM `.dark` class. No framework dependency — safe on any host.
70
+ */
71
+ export function AgnosticThemeToggle({
72
+ theme,
73
+ defaultTheme,
74
+ onToggle,
75
+ onThemeChange,
76
+ size = '$2',
77
+ label,
78
+ }: ThemeToggleProps) {
79
+ const controlled = theme !== undefined
80
+ const [internal, setInternal] = useState<ThemeMode>(() => theme ?? defaultTheme ?? readDomTheme() ?? 'dark')
81
+ const current: ThemeMode = controlled ? (theme as ThemeMode) : internal
82
+ const isDark = current === 'dark'
83
+
84
+ const toggle = () => {
85
+ const next: ThemeMode = isDark ? 'light' : 'dark'
86
+ if (!controlled) {
87
+ setInternal(next)
88
+ applyDomTheme(next)
89
+ }
90
+ onToggle?.(next)
91
+ onThemeChange?.(next)
92
+ }
93
+
16
94
  return (
17
95
  <Button
18
- size="$2"
96
+ size={size as never}
19
97
  chromeless
20
98
  icon={isDark ? <Sun size={16} /> : <Moon size={16} />}
21
- onPress={() => set(isDark ? 'light' : 'dark')}
22
- aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
99
+ onPress={toggle}
100
+ aria-label={label ?? (isDark ? 'Switch to light theme' : 'Switch to dark theme')}
23
101
  />
24
102
  )
25
103
  }
104
+
105
+ // @hanzogui/next-theme is an OPTIONAL dependency — loaded only on the uncontrolled,
106
+ // no-props path (console/Next). Lazily code-split so non-Next hosts never need it at
107
+ // runtime; if it fails to load, the boundary below degrades to the agnostic toggle.
108
+ const NextThemeToggle = lazy(() => import('./ThemeToggleNext'))
109
+
110
+ class NextThemeBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
111
+ state = { failed: false }
112
+ static getDerivedStateFromError() {
113
+ return { failed: true }
114
+ }
115
+ render() {
116
+ return this.state.failed ? this.props.fallback : this.props.children
117
+ }
118
+ }
119
+
120
+ export function ThemeToggle(props: ThemeToggleProps) {
121
+ const injected =
122
+ props.theme !== undefined || props.onToggle !== undefined || props.onThemeChange !== undefined
123
+ if (injected) return <AgnosticThemeToggle {...props} />
124
+
125
+ // No props → keep the existing console/Next behaviour via the optional next-theme
126
+ // path, degrading to the agnostic DOM toggle if it is not installed.
127
+ const fallback = <AgnosticThemeToggle {...props} />
128
+ return (
129
+ <NextThemeBoundary fallback={fallback}>
130
+ <Suspense fallback={fallback}>
131
+ <NextThemeToggle size={props.size} label={props.label} />
132
+ </Suspense>
133
+ </NextThemeBoundary>
134
+ )
135
+ }
@@ -0,0 +1,27 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * ThemeToggleNext — the @hanzogui/next-theme binding (console / Next). This is the ONLY
5
+ * module that imports @hanzogui/next-theme, so it stays an OPTIONAL dependency: the base
6
+ * ThemeToggle lazy-loads it (default export) and non-Next hosts never pull it in unless
7
+ * they import this component directly. UI is delegated to the framework-agnostic button
8
+ * in controlled mode, so every toggle looks identical.
9
+ */
10
+ import { useThemeSetting } from '@hanzogui/next-theme'
11
+ import { AgnosticThemeToggle, type ThemeToggleProps } from './ThemeToggle'
12
+
13
+ function ThemeToggleNext({ size, label }: Pick<ThemeToggleProps, 'size' | 'label'>) {
14
+ const { current, resolvedTheme, set } = useThemeSetting()
15
+ const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
16
+ return (
17
+ <AgnosticThemeToggle
18
+ theme={isDark ? 'dark' : 'light'}
19
+ onToggle={(next) => set(next)}
20
+ size={size}
21
+ label={label}
22
+ />
23
+ )
24
+ }
25
+
26
+ export default ThemeToggleNext
27
+ export { ThemeToggleNext }
@@ -40,11 +40,18 @@ export * from './combobox/filter'
40
40
 
41
41
  // The shared shell — brand mark, org scope + switcher, app header (the
42
42
  // console's org-scope contract + switcher hoisted here; hanzoai/ui#36).
43
+ // `surfaces.data` is the ONE canonical cross-surface list every launcher consumes.
44
+ export * from './surfaces.data'
43
45
  export * from './AppHeader'
44
46
  export * from './BrandMark'
45
47
  export * from './OrgSwitcher'
46
48
  export * from './scope'
47
49
 
50
+ // Menu — the ONE menu system: portal-theme-safe DropdownMenu (click) + ContextMenu
51
+ // (right-click), both rendering the SAME item spec (MenuPanel/MenuItemView) so every
52
+ // menu across the fleet is pixel-identical. SelectMenu/ComboBox share the same spec.
53
+ export * from './menu'
54
+
48
55
  // The rest — every exported name below is unique across the layer.
49
56
  export * from './DataTable'
50
57
  export * from './EmptyState'
@@ -60,6 +67,7 @@ export * from './SelectMenu'
60
67
  export * from './SlideOver'
61
68
  export * from './StatusTag'
62
69
  export * from './ThemeToggle'
70
+ export { ThemeToggleNext } from './ThemeToggleNext'
63
71
  export * from './Toast'
64
72
  export * from './color'
65
73