@hanzo/ui 8.0.1 → 8.0.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.
Files changed (89) hide show
  1. package/package.json +62 -3
  2. package/src/billing.ts +9 -0
  3. package/src/canvas.ts +11 -0
  4. package/src/cd/layout.ts +184 -0
  5. package/src/cd/status.ts +158 -0
  6. package/src/cd/time.ts +52 -0
  7. package/src/cd/types.ts +318 -0
  8. package/src/dashboard.ts +8 -0
  9. package/src/gitops.ts +5 -0
  10. package/src/network.ts +11 -0
  11. package/src/product/AppHeader.tsx +175 -0
  12. package/src/product/BrandMark.tsx +27 -0
  13. package/src/product/HanzoMark.tsx +7 -4
  14. package/src/product/OrgSwitcher.tsx +332 -0
  15. package/src/product/index.ts +11 -0
  16. package/src/product/scope.test.ts +92 -0
  17. package/src/product/scope.ts +123 -0
  18. package/src/usage.ts +8 -0
  19. package/src/wallet.ts +11 -0
  20. package/types/billing.d.ts +2 -0
  21. package/types/billing.d.ts.map +1 -0
  22. package/types/canvas.d.ts +2 -0
  23. package/types/canvas.d.ts.map +1 -0
  24. package/types/cd/layout.d.ts +57 -0
  25. package/types/cd/layout.d.ts.map +1 -0
  26. package/types/cd/status.d.ts +50 -0
  27. package/types/cd/status.d.ts.map +1 -0
  28. package/types/cd/time.d.ts +15 -0
  29. package/types/cd/time.d.ts.map +1 -0
  30. package/types/cd/types.d.ts +269 -0
  31. package/types/cd/types.d.ts.map +1 -0
  32. package/types/dashboard.d.ts +2 -0
  33. package/types/dashboard.d.ts.map +1 -0
  34. package/types/gitops.d.ts +2 -0
  35. package/types/gitops.d.ts.map +1 -0
  36. package/types/network.d.ts +2 -0
  37. package/types/network.d.ts.map +1 -0
  38. package/types/product/AppHeader.d.ts +49 -0
  39. package/types/product/AppHeader.d.ts.map +1 -0
  40. package/types/product/BrandMark.d.ts +8 -0
  41. package/types/product/BrandMark.d.ts.map +1 -0
  42. package/types/product/Charts.d.ts +5 -5
  43. package/types/product/Charts.d.ts.map +1 -1
  44. package/types/product/ComboBox.d.ts +1 -1
  45. package/types/product/ComboBox.d.ts.map +1 -1
  46. package/types/product/DataTable.d.ts +1 -1
  47. package/types/product/DataTable.d.ts.map +1 -1
  48. package/types/product/Donut.d.ts +1 -1
  49. package/types/product/Donut.d.ts.map +1 -1
  50. package/types/product/EmptyState.d.ts +1 -1
  51. package/types/product/EmptyState.d.ts.map +1 -1
  52. package/types/product/FadeIn.d.ts +1 -1
  53. package/types/product/FadeIn.d.ts.map +1 -1
  54. package/types/product/Field.d.ts +6 -6
  55. package/types/product/Field.d.ts.map +1 -1
  56. package/types/product/HanzoMark.d.ts +6 -5
  57. package/types/product/HanzoMark.d.ts.map +1 -1
  58. package/types/product/Metric.d.ts +4 -4
  59. package/types/product/Metric.d.ts.map +1 -1
  60. package/types/product/OrgSwitcher.d.ts +19 -0
  61. package/types/product/OrgSwitcher.d.ts.map +1 -0
  62. package/types/product/PageHeader.d.ts +1 -1
  63. package/types/product/PageHeader.d.ts.map +1 -1
  64. package/types/product/PrimaryButton.d.ts +1 -1
  65. package/types/product/PrimaryButton.d.ts.map +1 -1
  66. package/types/product/ProductIcon.d.ts +1 -1
  67. package/types/product/ProductIcon.d.ts.map +1 -1
  68. package/types/product/ProviderLogo.d.ts +2 -2
  69. package/types/product/ProviderLogo.d.ts.map +1 -1
  70. package/types/product/Reorder.d.ts +1 -1
  71. package/types/product/Reorder.d.ts.map +1 -1
  72. package/types/product/SelectMenu.d.ts +1 -1
  73. package/types/product/SelectMenu.d.ts.map +1 -1
  74. package/types/product/SlideOver.d.ts +1 -1
  75. package/types/product/SlideOver.d.ts.map +1 -1
  76. package/types/product/StatusTag.d.ts +1 -1
  77. package/types/product/StatusTag.d.ts.map +1 -1
  78. package/types/product/ThemeToggle.d.ts +1 -1
  79. package/types/product/ThemeToggle.d.ts.map +1 -1
  80. package/types/product/Toast.d.ts +1 -1
  81. package/types/product/Toast.d.ts.map +1 -1
  82. package/types/product/index.d.ts +4 -0
  83. package/types/product/index.d.ts.map +1 -1
  84. package/types/product/scope.d.ts +56 -0
  85. package/types/product/scope.d.ts.map +1 -0
  86. package/types/usage.d.ts +2 -0
  87. package/types/usage.d.ts.map +1 -0
  88. package/types/wallet.d.ts +2 -0
  89. package/types/wallet.d.ts.map +1 -0
@@ -0,0 +1,332 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * OrgSwitcher — the Vercel-style org/team switcher, hoisted from the console
5
+ * (hanzoai/ui#36) onto @hanzo/gui primitives. Shows the org the surface is
6
+ * scoped to, switches between the orgs the caller can see, and creates one.
7
+ *
8
+ * Host-agnostic: the LIST is a lazy, paged loader the host injects (IAM
9
+ * `get-organizations` behind its own proxy) — one page at a time, the search
10
+ * term pushed to the server, more on demand (scroll + "Load more"), so it
11
+ * scales to thousands of orgs. No loader → the current org alone, synthesized
12
+ * from the scope (never fabricated). Selecting re-scopes IN PLACE via
13
+ * `scope.switchOrg` (persist + reload → every module refetches under the new
14
+ * `X-Org-Id`). Create posts through the injected hook, then scopes into the
15
+ * new org.
16
+ */
17
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
18
+ import { Button, Input, Popover, Spinner, Text, XStack, YStack } from '@hanzo/gui'
19
+ import { Check, ChevronsUpDown, LayoutGrid, Plus, Search } from '@hanzogui/lucide-icons-2'
20
+
21
+ import { filterOrgs, type Org, type OrgScope } from './scope'
22
+
23
+ const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
24
+ const initialsOf = (o: Org) =>
25
+ (o.displayName || o.name)
26
+ .split(/\s+/)
27
+ .map((w) => w[0])
28
+ .join('')
29
+ .slice(0, 2)
30
+ .toUpperCase()
31
+
32
+ /** An org's avatar — its logo when set, else a monogram tile. */
33
+ function OrgAvatar({ org, size = 22 }: { org: Org; size?: number }) {
34
+ if (org.logo) {
35
+ // eslint-disable-next-line @next/next/no-img-element
36
+ return <img src={org.logo} alt="" style={{ height: size, width: size, objectFit: 'contain', display: 'block', borderRadius: 6 }} />
37
+ }
38
+ return (
39
+ <YStack width={size} height={size} rounded="$3" bg="$color4" items="center" justify="center">
40
+ <Text fontSize="$1" fontWeight="800" color="$color12">
41
+ {initialsOf(org)}
42
+ </Text>
43
+ </YStack>
44
+ )
45
+ }
46
+
47
+ export type OrgSwitcherProps = {
48
+ /** The active-org contract (see `orgScope`). */
49
+ scope: OrgScope
50
+ /**
51
+ * Lazy paged org list: 0-based page + server-pushed search → one page of
52
+ * rows. A full page (=== pageSize) implies another. Omit when the caller
53
+ * can only see its own org.
54
+ */
55
+ orgs?: (page: number, query: string) => Promise<Org[]>
56
+ /** Rows per page the loader returns. Default 20. */
57
+ pageSize?: number
58
+ /** Create-org hook → the created org's id; omit to hide the affordance. */
59
+ create?: (name: string) => Promise<string>
60
+ /** Show the "All organizations" de-scope row (`scope.leaveOrg`). */
61
+ picker?: boolean
62
+ }
63
+
64
+ export function OrgSwitcher({ scope, orgs, pageSize = 20, create, picker = false }: OrgSwitcherProps) {
65
+ const currentId = scope.currentOrg()
66
+
67
+ const [open, setOpen] = useState(false)
68
+ const [query, setQuery] = useState('')
69
+ const [debounced, setDebounced] = useState('')
70
+ const [rows, setRows] = useState<Org[]>([])
71
+ const [loading, setLoading] = useState(false)
72
+ const [loadingMore, setLoadingMore] = useState(false)
73
+ const [hasMore, setHasMore] = useState(false)
74
+ const [creating, setCreating] = useState(false)
75
+ const [newName, setNewName] = useState('')
76
+ const [busy, setBusy] = useState(false)
77
+ const [err, setErr] = useState<string | null>(null)
78
+ const pageRef = useRef(0)
79
+ const reqRef = useRef(0) // race token — a newer request supersedes older ones
80
+
81
+ // Debounce the search so type-as-you-search re-hits the server at most ~4×/s.
82
+ useEffect(() => {
83
+ const id = setTimeout(() => setDebounced(query.trim()), 250)
84
+ return () => clearTimeout(id)
85
+ }, [query])
86
+
87
+ const fetchPage = useCallback(
88
+ async (page: number, q: string, append: boolean) => {
89
+ if (!orgs) return
90
+ const token = ++reqRef.current
91
+ append ? setLoadingMore(true) : setLoading(true)
92
+ try {
93
+ const incoming = await orgs(page, q)
94
+ if (token !== reqRef.current) return
95
+ pageRef.current = page
96
+ setRows((prev) => {
97
+ const merged = append ? [...prev] : []
98
+ const seen = new Set(merged.map((o) => o.name))
99
+ for (const o of incoming) if (!seen.has(o.name)) merged.push(o)
100
+ return merged
101
+ })
102
+ setHasMore(incoming.length >= pageSize)
103
+ } catch {
104
+ // Honest empty / keep what's shown — never a fabricated list.
105
+ if (token !== reqRef.current) return
106
+ if (!append) setRows([])
107
+ setHasMore(false)
108
+ } finally {
109
+ if (token === reqRef.current) {
110
+ setLoading(false)
111
+ setLoadingMore(false)
112
+ }
113
+ }
114
+ },
115
+ [orgs, pageSize],
116
+ )
117
+
118
+ // (Re)load page 0 when the popover opens or the debounced query changes.
119
+ useEffect(() => {
120
+ if (!open || !orgs) return
121
+ void fetchPage(0, debounced, false)
122
+ }, [open, debounced, fetchPage, orgs])
123
+
124
+ const loadMore = useCallback(() => {
125
+ if (loading || loadingMore || !hasMore) return
126
+ void fetchPage(pageRef.current + 1, debounced, true)
127
+ }, [loading, loadingMore, hasMore, debounced, fetchPage])
128
+
129
+ // Rows to render — the loaded list (client-filtered too), else the current
130
+ // org synthesized from the scope.
131
+ const visible: Org[] = useMemo(() => {
132
+ if (orgs) return filterOrgs(rows, query)
133
+ return [{ name: currentId, displayName: titleCase(currentId) }]
134
+ }, [orgs, rows, query, currentId])
135
+
136
+ const current: Org = useMemo(
137
+ () => rows.find((o) => o.name === currentId) ?? { name: currentId, displayName: titleCase(currentId) },
138
+ [rows, currentId],
139
+ )
140
+
141
+ const select = useCallback(
142
+ (org: string) => {
143
+ setOpen(false)
144
+ scope.switchOrg(org)
145
+ },
146
+ [scope],
147
+ )
148
+
149
+ const submitCreate = async () => {
150
+ const name = newName.trim()
151
+ if (!name || !create) return
152
+ setBusy(true)
153
+ setErr(null)
154
+ try {
155
+ const org = await create(name)
156
+ scope.switchOrg(org)
157
+ } catch (e) {
158
+ setErr(e instanceof Error ? e.message : 'Could not create the organization.')
159
+ setBusy(false)
160
+ }
161
+ }
162
+
163
+ return (
164
+ <Popover open={open} onOpenChange={setOpen} placement="bottom-start">
165
+ <Popover.Trigger asChild>
166
+ <Button size="$2" chromeless icon={<OrgAvatar org={current} size={18} />} iconAfter={<ChevronsUpDown size={13} />}>
167
+ {current.displayName || titleCase(current.name)}
168
+ </Button>
169
+ </Popover.Trigger>
170
+ <Popover.Content bordered elevate p="$2" width={300} bg="$color2" borderColor="$borderColor">
171
+ {creating ? (
172
+ <YStack gap="$2">
173
+ <Text fontSize="$2" color="$color12" fontWeight="700">
174
+ Create organization
175
+ </Text>
176
+ <Input
177
+ size="$3"
178
+ placeholder="Organization name"
179
+ value={newName}
180
+ onChangeText={setNewName}
181
+ autoCapitalize="words"
182
+ onSubmitEditing={() => void submitCreate()}
183
+ />
184
+ {err ? (
185
+ <Text fontSize="$1" color="$red10">
186
+ {err}
187
+ </Text>
188
+ ) : null}
189
+ <XStack gap="$2" justify="flex-end">
190
+ <Button size="$2" chromeless onPress={() => setCreating(false)} disabled={busy}>
191
+ Cancel
192
+ </Button>
193
+ <Button
194
+ size="$2"
195
+ onPress={() => void submitCreate()}
196
+ disabled={busy || !newName.trim()}
197
+ icon={busy ? <Spinner size="small" /> : <Plus size={14} />}
198
+ >
199
+ Create
200
+ </Button>
201
+ </XStack>
202
+ </YStack>
203
+ ) : (
204
+ <YStack gap="$1">
205
+ {orgs ? (
206
+ <XStack items="center" gap="$2" px="$2" py="$1" rounded="$3" borderWidth={1} borderColor="$borderColor">
207
+ <Search size={13} opacity={0.6} />
208
+ <Input
209
+ flex={1}
210
+ size="$2"
211
+ borderWidth={0}
212
+ bg="transparent"
213
+ placeholder="Find organization…"
214
+ value={query}
215
+ onChangeText={setQuery}
216
+ autoCapitalize="none"
217
+ autoCorrect={false}
218
+ />
219
+ </XStack>
220
+ ) : null}
221
+
222
+ <div
223
+ style={{ maxHeight: 300, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}
224
+ onScroll={(e) => {
225
+ const el = e.currentTarget
226
+ if (el.scrollHeight - el.scrollTop - el.clientHeight < 48) loadMore()
227
+ }}
228
+ >
229
+ {loading ? (
230
+ <XStack items="center" gap="$2" px="$2" py="$3">
231
+ <Spinner size="small" color="$color11" />
232
+ <Text fontSize="$2" color="$color10">
233
+ Loading organizations…
234
+ </Text>
235
+ </XStack>
236
+ ) : visible.length === 0 ? (
237
+ <Text px="$2" py="$2" fontSize="$2" color="$color10">
238
+ {query.trim() ? `No organizations match “${query.trim()}”.` : 'No organizations yet.'}
239
+ </Text>
240
+ ) : (
241
+ visible.map((org) => (
242
+ <XStack
243
+ key={org.name}
244
+ onPress={() => select(org.name)}
245
+ cursor="pointer"
246
+ items="center"
247
+ gap="$2.5"
248
+ px="$2"
249
+ py="$2"
250
+ rounded="$3"
251
+ bg={org.name === currentId ? '$color4' : 'transparent'}
252
+ hoverStyle={{ bg: '$color5' }}
253
+ >
254
+ <OrgAvatar org={org} />
255
+ <Text flex={1} fontSize="$2" color="$color12" numberOfLines={1}>
256
+ {org.displayName || org.name}
257
+ </Text>
258
+ {org.name === currentId ? <Check size={15} /> : null}
259
+ </XStack>
260
+ ))
261
+ )}
262
+
263
+ {loadingMore ? (
264
+ <XStack items="center" gap="$2" px="$2" py="$2">
265
+ <Spinner size="small" color="$color11" />
266
+ <Text fontSize="$1" color="$color10">
267
+ Loading more…
268
+ </Text>
269
+ </XStack>
270
+ ) : hasMore ? (
271
+ <XStack onPress={loadMore} cursor="pointer" items="center" justify="center" px="$2" py="$1.5" rounded="$3" hoverStyle={{ bg: '$color4' }}>
272
+ <Text fontSize="$1" color="$color11" fontWeight="600">
273
+ Load more
274
+ </Text>
275
+ </XStack>
276
+ ) : null}
277
+ </div>
278
+
279
+ {create ? (
280
+ <XStack
281
+ onPress={() => {
282
+ setCreating(true)
283
+ setErr(null)
284
+ }}
285
+ cursor="pointer"
286
+ items="center"
287
+ gap="$2.5"
288
+ px="$2"
289
+ py="$2"
290
+ mt="$1"
291
+ rounded="$3"
292
+ borderTopWidth={1}
293
+ borderColor="$borderColor"
294
+ hoverStyle={{ bg: '$color5' }}
295
+ >
296
+ <YStack width={22} height={22} rounded="$3" bg="$color3" items="center" justify="center">
297
+ <Plus size={14} />
298
+ </YStack>
299
+ <Text fontSize="$2" color="$color12">
300
+ Create organization
301
+ </Text>
302
+ </XStack>
303
+ ) : null}
304
+
305
+ {picker ? (
306
+ <XStack
307
+ onPress={() => {
308
+ setOpen(false)
309
+ scope.leaveOrg()
310
+ }}
311
+ cursor="pointer"
312
+ items="center"
313
+ gap="$2.5"
314
+ px="$2"
315
+ py="$2"
316
+ rounded="$3"
317
+ hoverStyle={{ bg: '$color5' }}
318
+ >
319
+ <YStack width={22} height={22} rounded="$3" bg="$color3" items="center" justify="center">
320
+ <LayoutGrid size={14} />
321
+ </YStack>
322
+ <Text fontSize="$2" color="$color12">
323
+ All organizations
324
+ </Text>
325
+ </XStack>
326
+ ) : null}
327
+ </YStack>
328
+ )}
329
+ </Popover.Content>
330
+ </Popover>
331
+ )
332
+ }
@@ -38,6 +38,13 @@ export { Donut as DonutRing, type DonutSegment } from './Donut'
38
38
  export { ComboBox } from './ComboBox'
39
39
  export * from './combobox/filter'
40
40
 
41
+ // The shared shell — brand mark, org scope + switcher, app header (the
42
+ // console's org-scope contract + switcher hoisted here; hanzoai/ui#36).
43
+ export * from './AppHeader'
44
+ export * from './BrandMark'
45
+ export * from './OrgSwitcher'
46
+ export * from './scope'
47
+
41
48
  // The rest — every exported name below is unique across the layer.
42
49
  export * from './DataTable'
43
50
  export * from './EmptyState'
@@ -55,3 +62,7 @@ export * from './StatusTag'
55
62
  export * from './ThemeToggle'
56
63
  export * from './Toast'
57
64
  export * from './color'
65
+
66
+ // Usage — the canonical AI-usage surface (totals, chart, spend-by-model, activity)
67
+ // now lives in ONE home: `@hanzo/usage/panel` (<UsagePanel>). The former meter/card/
68
+ // dashboard kit here was orphaned (zero consumers) and folded into it.
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { filterOrgs, orgScope } from './scope'
4
+
5
+ /** In-memory Storage double. */
6
+ function memory(): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
7
+ const m = new Map<string, string>()
8
+ return {
9
+ getItem: (k) => m.get(k) ?? null,
10
+ setItem: (k, v) => void m.set(k, v),
11
+ removeItem: (k) => void m.delete(k),
12
+ }
13
+ }
14
+
15
+ function harness() {
16
+ const calls: string[] = []
17
+ const scope = orgScope({
18
+ brandOrg: 'hanzo',
19
+ storage: memory(),
20
+ navigate: (path) => calls.push(`nav:${path}`),
21
+ reload: () => calls.push('reload'),
22
+ })
23
+ return { scope, calls }
24
+ }
25
+
26
+ describe('orgScope', () => {
27
+ it('defaults to the brand org, un-selected', () => {
28
+ const { scope } = harness()
29
+ expect(scope.currentOrg()).toBe('hanzo')
30
+ expect(scope.isScopedAway()).toBe(false)
31
+ expect(scope.hasSelectedOrg()).toBe(false)
32
+ })
33
+
34
+ it('setCurrentOrg persists; the brand org clears the override', () => {
35
+ const { scope } = harness()
36
+ scope.setCurrentOrg('acme')
37
+ expect(scope.currentOrg()).toBe('acme')
38
+ expect(scope.isScopedAway()).toBe(true)
39
+ scope.setCurrentOrg('hanzo')
40
+ expect(scope.currentOrg()).toBe('hanzo')
41
+ expect(scope.isScopedAway()).toBe(false)
42
+ })
43
+
44
+ it('enterOrg selects + scopes + navigates home', () => {
45
+ const { scope, calls } = harness()
46
+ scope.enterOrg('acme')
47
+ expect(scope.currentOrg()).toBe('acme')
48
+ expect(scope.hasSelectedOrg()).toBe(true)
49
+ expect(calls).toEqual(['nav:/'])
50
+ })
51
+
52
+ it('leaveOrg de-selects, resets to the brand org, navigates home', () => {
53
+ const { scope, calls } = harness()
54
+ scope.enterOrg('acme')
55
+ scope.leaveOrg()
56
+ expect(scope.currentOrg()).toBe('hanzo')
57
+ expect(scope.hasSelectedOrg()).toBe(false)
58
+ expect(calls).toEqual(['nav:/', 'nav:/'])
59
+ })
60
+
61
+ it('switchOrg persists, keeps the selection, reloads; same org is a no-op', () => {
62
+ const { scope, calls } = harness()
63
+ scope.enterOrg('acme')
64
+ scope.switchOrg('zoo')
65
+ expect(scope.currentOrg()).toBe('zoo')
66
+ expect(scope.hasSelectedOrg()).toBe(true)
67
+ expect(calls).toEqual(['nav:/', 'reload'])
68
+ scope.switchOrg('zoo')
69
+ expect(calls).toEqual(['nav:/', 'reload'])
70
+ })
71
+
72
+ it('two scopes with different keys never collide', () => {
73
+ const store = memory()
74
+ const a = orgScope({ brandOrg: 'hanzo', key: 'a', storage: store })
75
+ const b = orgScope({ brandOrg: 'hanzo', key: 'b', storage: store })
76
+ a.setCurrentOrg('acme')
77
+ expect(b.currentOrg()).toBe('hanzo')
78
+ })
79
+ })
80
+
81
+ describe('filterOrgs', () => {
82
+ const orgs = [
83
+ { name: 'acme', displayName: 'Acme Corp' },
84
+ { name: 'zoo', displayName: 'Zoo Labs' },
85
+ ]
86
+ it('matches name and displayName, case-insensitive; blank returns all', () => {
87
+ expect(filterOrgs(orgs, '')).toHaveLength(2)
88
+ expect(filterOrgs(orgs, 'ACME')).toEqual([orgs[0]])
89
+ expect(filterOrgs(orgs, 'labs')).toEqual([orgs[1]])
90
+ expect(filterOrgs(orgs, 'nope')).toEqual([])
91
+ })
92
+ })
@@ -0,0 +1,123 @@
1
+ /**
2
+ * orgScope — the active-organization contract every Hanzo surface shares
3
+ * (hoisted from the console; see hanzoai/ui#36).
4
+ *
5
+ * Brand identity (which IAM you log into, the wordmark + logo) is fixed per
6
+ * host; the DATA scope — which org's identity, secrets and resources the app
7
+ * acts on — is orthogonal. This module owns only that switch. Every
8
+ * tenant-scoped call reads `currentOrg()` and stamps it as `X-Org-Id`.
9
+ *
10
+ * Two-level model: the org VALUE (which org) and the SELECTION (has one been
11
+ * chosen yet) are orthogonal. A fresh login is org-LESS: `hasSelectedOrg()` is
12
+ * false so the host can show its org picker even for a one-org user. Entering
13
+ * (`enterOrg`) sets value + flag and navigates home; leaving (`leaveOrg`)
14
+ * clears the flag back to the picker; the in-shell quick switch (`switchOrg`)
15
+ * persists and reloads so every module refetches under the new `X-Org-Id`.
16
+ *
17
+ * Host-agnostic: storage + navigation are injected (browser defaults), so the
18
+ * same contract runs in Next, Vite, Svelte or a test.
19
+ */
20
+
21
+ export type Org = { name: string; displayName?: string; logo?: string }
22
+
23
+ export type OrgScopeConfig = {
24
+ /** The brand's own org — the default scope (e.g. `config.iamOrgName`). */
25
+ brandOrg: string
26
+ /** Storage key namespace — ONE per surface. Default `'hanzo.org'`. */
27
+ key?: string
28
+ /** Storage — defaults to `window.localStorage`, null-safe on SSR/blocked. */
29
+ storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
30
+ /** Navigate to a path (enter/leave land on home). Default `location.assign`. */
31
+ navigate?: (path: string) => void
32
+ /** Hard reload (quick-switch refetches everything). Default `location.reload`. */
33
+ reload?: () => void
34
+ }
35
+
36
+ export type OrgScope = {
37
+ /** The org the surface is currently scoped to (default: the brand org). */
38
+ currentOrg(): string
39
+ /** Switch the active scope. Passing the brand org clears the override. */
40
+ setCurrentOrg(org: string): void
41
+ /** True when scoped to a non-default (switched) org. */
42
+ isScopedAway(): boolean
43
+ /** True once an org has been explicitly ENTERED (picker → shell). */
44
+ hasSelectedOrg(): boolean
45
+ /** Enter an org from the picker: scope + select + navigate home. */
46
+ enterOrg(org: string): void
47
+ /** Leave the org (back to the picker): de-select + reset + navigate home. */
48
+ leaveOrg(): void
49
+ /** In-shell quick switch: persist + keep selection + reload. Same org: no-op. */
50
+ switchOrg(org: string): void
51
+ }
52
+
53
+ function browserStorage(): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null {
54
+ if (typeof window === 'undefined') return null
55
+ try {
56
+ return window.localStorage
57
+ } catch {
58
+ return null
59
+ }
60
+ }
61
+
62
+ export function orgScope(config: OrgScopeConfig): OrgScope {
63
+ const key = config.key ?? 'hanzo.org'
64
+ const selectedKey = `${key}.selected`
65
+ const store = () => config.storage ?? browserStorage()
66
+ const read = (k: string): string | null => {
67
+ try {
68
+ return store()?.getItem(k) ?? null
69
+ } catch {
70
+ return null
71
+ }
72
+ }
73
+ const write = (k: string, v: string | null): void => {
74
+ try {
75
+ if (v === null) store()?.removeItem(k)
76
+ else store()?.setItem(k, v)
77
+ } catch {
78
+ // Storage blocked — the scope simply stays at the brand org.
79
+ }
80
+ }
81
+ const goHome = () => {
82
+ if (config.navigate) return config.navigate('/')
83
+ if (typeof window !== 'undefined') window.location.assign('/')
84
+ }
85
+ const doReload = () => {
86
+ if (config.reload) return config.reload()
87
+ if (typeof window !== 'undefined') window.location.reload()
88
+ }
89
+
90
+ const scope: OrgScope = {
91
+ currentOrg: () => read(key) || config.brandOrg,
92
+ setCurrentOrg: (org) => write(key, !org || org === config.brandOrg ? null : org),
93
+ isScopedAway: () => scope.currentOrg() !== config.brandOrg,
94
+ hasSelectedOrg: () => read(selectedKey) === '1',
95
+ enterOrg: (org) => {
96
+ if (!org) return
97
+ scope.setCurrentOrg(org)
98
+ write(selectedKey, '1')
99
+ goHome()
100
+ },
101
+ leaveOrg: () => {
102
+ write(selectedKey, null)
103
+ scope.setCurrentOrg(config.brandOrg)
104
+ goHome()
105
+ },
106
+ switchOrg: (org) => {
107
+ if (!org || org === scope.currentOrg()) return
108
+ scope.setCurrentOrg(org)
109
+ write(selectedKey, '1')
110
+ doReload()
111
+ },
112
+ }
113
+ return scope
114
+ }
115
+
116
+ /** Case-insensitive filter over org name + display name (the switcher search). */
117
+ export function filterOrgs<T extends Org>(orgs: T[], query: string): T[] {
118
+ const q = query.trim().toLowerCase()
119
+ if (!q) return orgs
120
+ return orgs.filter(
121
+ (o) => o.name.toLowerCase().includes(q) || (o.displayName ?? '').toLowerCase().includes(q),
122
+ )
123
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,8 @@
1
+ // @hanzo/ui/usage — the shared AI-usage surface (the meter bar → provider card →
2
+ // dashboard progression), re-exported from its home package @hanzo/usage. Thin
3
+ // subpath so every console shows the same quota UI:
4
+ //
5
+ // import { UsageMeter, UsageProviderCard, UsageDashboard } from '@hanzo/ui/usage'
6
+ //
7
+ // Optional peer — only pulled when the subpath is used.
8
+ export * from '@hanzo/usage'
package/src/wallet.ts ADDED
@@ -0,0 +1,11 @@
1
+ // @hanzo/ui/wallet — the ONE wallet surface, re-exported from its home module
2
+ // @hanzo/ui-shadcn/wallet. `WalletMenu` is presentation only (address, PQ
3
+ // identity, selected-network alignment, explorer link, connect/disconnect);
4
+ // custody lives in the injected `WalletAdapter` (desktop wallet, web EIP-1193).
5
+ // Thin subpath so every console imports the same wallet surface:
6
+ //
7
+ // import { WalletMenu, injectedEvmAdapter, walletAvailable, ensureEvmNetwork,
8
+ // shortAddress, type WalletAdapter } from '@hanzo/ui/wallet'
9
+ //
10
+ // Optional peer — only pulled when the subpath is used.
11
+ export * from '@hanzo/ui-shadcn/wallet'
@@ -0,0 +1,2 @@
1
+ export * from '@hanzo/ui-shadcn/billing';
2
+ //# sourceMappingURL=billing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"billing.d.ts","sourceRoot":"","sources":["../src/billing.ts"],"names":[],"mappings":"AAQA,cAAc,0BAA0B,CAAA"}
@@ -0,0 +1,2 @@
1
+ export * from '@hanzo/canvas';
2
+ //# sourceMappingURL=canvas.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canvas.d.ts","sourceRoot":"","sources":["../src/canvas.ts"],"names":[],"mappings":"AAUA,cAAc,eAAe,CAAA"}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Deterministic layered graph layout — pure, no React, no DOM, unit-tested. This
3
+ * is the dependency-free equivalent of the GitOps engine UI's dagre `rankdir:LR`
4
+ * pass: resources flow left→right by ownership depth, each tier a clean column.
5
+ *
6
+ * Three folds:
7
+ * 1. columns — longest-path layering (Kahn topological order). A root (no
8
+ * incoming edge) sits in column 0; every other node one column right of its
9
+ * deepest parent. Cycles degrade gracefully (a child keeps its best column),
10
+ * so a malformed graph can never wedge the layout.
11
+ * 2. order — within a column, nodes are ordered by the barycentre of their
12
+ * parents' rows (then name), so children cluster under their owner and edge
13
+ * crossings stay low — dagre's crossing-reduction, cheaply.
14
+ * 3. place — nodes stack top-to-bottom at their own height (pods are taller
15
+ * than controllers) with a fixed gap, and each column is vertically centred
16
+ * on a shared axis so tiers read as balanced columns.
17
+ *
18
+ * The same graph always yields byte-identical positions.
19
+ */
20
+ export interface LayoutNode {
21
+ id: string;
22
+ /** Rendered height in px (pods are taller than controllers). */
23
+ h: number;
24
+ }
25
+ export interface LayoutEdge {
26
+ source: string;
27
+ target: string;
28
+ }
29
+ export interface LayoutOptions {
30
+ /** Uniform node width in px (x is placed on a fixed column pitch). */
31
+ nodeWidth?: number;
32
+ /** Horizontal gap between a column's right edge and the next column. */
33
+ columnGap?: number;
34
+ /** Vertical gap between stacked nodes in a column. */
35
+ rowGap?: number;
36
+ }
37
+ export interface PositionedNode {
38
+ id: string;
39
+ /** Top-left corner, px. */
40
+ x: number;
41
+ y: number;
42
+ w: number;
43
+ h: number;
44
+ col: number;
45
+ }
46
+ export interface GraphLayout {
47
+ nodes: PositionedNode[];
48
+ /** Content bounds — the transformed layer's intrinsic size. */
49
+ width: number;
50
+ height: number;
51
+ }
52
+ /**
53
+ * Lay a resource graph out into centred LR columns. Returns one positioned node
54
+ * per input node (input order preserved in the result array) plus content bounds.
55
+ */
56
+ export declare function layoutTree(nodes: LayoutNode[], edges: LayoutEdge[], opts?: LayoutOptions): GraphLayout;
57
+ //# sourceMappingURL=layout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../../src/cd/layout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAA;IACV,gEAAgE;IAChE,CAAC,EAAE,MAAM,CAAA;CACV;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAA;IACV,2BAA2B;IAC3B,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,GAAG,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AA6CD;;;GAGG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,UAAU,EAAE,EACnB,KAAK,EAAE,UAAU,EAAE,EACnB,IAAI,GAAE,aAAkB,GACvB,WAAW,CA2Eb"}