@exegia/corpora-ui 0.19.0 → 0.21.0

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 (162) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/auth-state-atom.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
  3. package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
  4. package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
  5. package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
  6. package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
  7. package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
  8. package/dist-lib/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.d.ts +1 -0
  9. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
  10. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
  11. package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
  12. package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
  13. package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
  14. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +191 -29
  15. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
  16. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +27 -31
  17. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +6 -1
  18. package/dist-lib/components/blocks/profile/index.d.ts +7 -0
  19. package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
  20. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
  21. package/dist-lib/components/blocks/profile/type.d.ts +80 -0
  22. package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
  23. package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
  24. package/dist-lib/components/blocks/scaffold/constants.d.ts +5 -0
  25. package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
  26. package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
  27. package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +6 -1
  28. package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
  29. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
  30. package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
  31. package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
  32. package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
  33. package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
  34. package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
  35. package/dist-lib/components/blocks/shell/index.d.ts +4 -0
  36. package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
  37. package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
  38. package/dist-lib/components/blocks/shell/type.d.ts +115 -3
  39. package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
  40. package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
  41. package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
  42. package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
  43. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  44. package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
  45. package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
  46. package/dist-lib/components/composed/tree/constants.d.ts +35 -0
  47. package/dist-lib/components/composed/tree/index.d.ts +6 -0
  48. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  49. package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
  50. package/dist-lib/components/composed/tree/tree-node.d.ts +12 -0
  51. package/dist-lib/components/composed/tree/tree.d.ts +19 -0
  52. package/dist-lib/components/composed/tree/type.d.ts +290 -0
  53. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -0
  54. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  55. package/dist-lib/components/composed/tree/use-tree.d.ts +24 -0
  56. package/dist-lib/components/composed/tree/utils.d.ts +28 -0
  57. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  58. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  59. package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
  60. package/dist-lib/components/user-avatar/component.d.ts +3 -0
  61. package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
  62. package/dist-lib/components/user-avatar/index.d.ts +18 -0
  63. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  64. package/dist-lib/components/user-avatar/type.d.ts +71 -0
  65. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  66. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
  67. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
  68. package/dist-lib/components/user-avatar/utils.d.ts +2 -0
  69. package/dist-lib/index.d.ts +6 -2
  70. package/dist-lib/index.js +3576 -1366
  71. package/dist-lib/index.js.map +1 -1
  72. package/dist-lib/state/exegia-provider.d.ts +51 -0
  73. package/dist-lib/state/index.d.ts +4 -0
  74. package/dist-lib/state/store.d.ts +17 -0
  75. package/package.json +16 -12
  76. package/src/components/beste/piece/browser-frame.tsx +9 -6
  77. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  78. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  79. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  80. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  81. package/src/components/blocks/auth/auth-state.ts +52 -0
  82. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  83. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  84. package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
  85. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  86. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +112 -76
  87. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  88. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  89. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  90. package/src/components/blocks/nav/sidebar/type.ts +228 -29
  91. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  92. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +455 -225
  93. package/src/components/blocks/nav/sidebar/utils.ts +29 -1
  94. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  95. package/src/components/blocks/profile/index.ts +28 -0
  96. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  97. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  98. package/src/components/blocks/profile/type.ts +95 -0
  99. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  100. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  101. package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +160 -1
  102. package/src/components/blocks/scaffold/constants.ts +7 -0
  103. package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
  104. package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
  105. package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
  106. package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
  107. package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
  108. package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
  109. package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
  110. package/src/components/blocks/scaffold/scaffold-tab.tsx +45 -7
  111. package/src/components/blocks/scaffold/type.ts +28 -0
  112. package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
  113. package/src/components/blocks/scaffold/utils.ts +19 -0
  114. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  115. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  116. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  117. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  118. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  119. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  120. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  121. package/src/components/blocks/shell/index.ts +18 -0
  122. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  123. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  124. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  125. package/src/components/blocks/shell/type.ts +130 -3
  126. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  127. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  128. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  129. package/src/components/blocks/shell/utils.ts +44 -4
  130. package/src/components/composed/tree/CLAUDE.md +132 -0
  131. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  132. package/src/components/composed/tree/__tests__/tree.test.tsx +525 -0
  133. package/src/components/composed/tree/__tests__/use-tree.test.tsx +333 -0
  134. package/src/components/composed/tree/constants.ts +60 -0
  135. package/src/components/composed/tree/index.ts +51 -0
  136. package/src/components/composed/tree/tree-atom.ts +590 -0
  137. package/src/components/composed/tree/tree-context.ts +13 -0
  138. package/src/components/composed/tree/tree-node.tsx +490 -0
  139. package/src/components/composed/tree/tree.tsx +286 -0
  140. package/src/components/composed/tree/type.ts +322 -0
  141. package/src/components/composed/tree/use-tree-dnd.ts +179 -0
  142. package/src/components/composed/tree/use-tree-state.ts +105 -0
  143. package/src/components/composed/tree/use-tree.ts +307 -0
  144. package/src/components/composed/tree/utils.ts +172 -0
  145. package/src/components/composed/user-avatar.tsx +11 -96
  146. package/src/components/docs/component-preview.tsx +11 -11
  147. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
  148. package/src/components/user-avatar/audio-wave.tsx +22 -0
  149. package/src/components/user-avatar/component.tsx +152 -0
  150. package/src/components/user-avatar/fallback.tsx +26 -0
  151. package/src/components/user-avatar/index.ts +34 -0
  152. package/src/components/user-avatar/presence-badge.tsx +56 -0
  153. package/src/components/user-avatar/type.ts +85 -0
  154. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  155. package/src/components/user-avatar/use-user-avatar.ts +144 -0
  156. package/src/components/user-avatar/user-avatar-atom.ts +198 -0
  157. package/src/components/user-avatar/utils.ts +8 -0
  158. package/src/index.css +211 -153
  159. package/src/index.ts +35 -2
  160. package/src/state/exegia-provider.tsx +79 -0
  161. package/src/state/index.ts +4 -0
  162. package/src/state/store.ts +19 -0
@@ -8,9 +8,16 @@ import {
8
8
  UserPlusIcon,
9
9
  UsersIcon,
10
10
  } from "lucide-react"
11
+ import { motion, useReducedMotion } from "motion/react"
11
12
  import * as React from "react"
12
13
 
13
- import { UserAvatar } from "@/components/composed/user-avatar"
14
+ import {
15
+ AnimatedSidebarPanelContext,
16
+ LABEL_ENTER_TRANSITION,
17
+ LABEL_EXIT_TRANSITION,
18
+ REDUCED_TRANSITION,
19
+ } from "@/components/blocks/shell/utils"
20
+ import { UserAvatar } from "@/components/user-avatar"
14
21
  import { Button } from "@/components/ui/button"
15
22
  import {
16
23
  DropdownMenu,
@@ -26,48 +33,25 @@ import {
26
33
  import { playCue } from "@/lib/sound"
27
34
  import { cn } from "@/lib/utils"
28
35
 
29
- /** The identity shown on the card. */
30
- export interface ProfileCardUser {
31
- name: string
32
- /** Secondary line under the name — a handle, an email, a role. */
33
- username?: string
34
- /** Avatar image URL. Falls back to the initials when absent or broken. */
35
- avatar?: string
36
- /** Fallback initials. Derived from `name` when omitted. */
37
- initials?: string
38
- }
39
-
40
- /** An actionable row of the menu. */
41
- export interface ProfileCardAction {
42
- type?: "item"
43
- id: string
44
- label: string
45
- icon?: React.ReactNode
46
- /** Right-aligned hint, e.g. "⌘K". Purely decorative — bind the key yourself. */
47
- shortcut?: string
48
- disabled?: boolean
49
- variant?: "default" | "destructive"
50
- /** Returning a promise puts the card in its loading state until it settles. */
51
- onSelect?: () => void | Promise<void>
52
- }
53
-
54
- /** A heading over the rows that follow it, up to the next separator. */
55
- export interface ProfileCardLabel {
56
- type: "label"
57
- label: string
58
- }
59
-
60
- /** A rule between two sections. */
61
- export interface ProfileCardSeparator {
62
- type: "separator"
63
- }
36
+ import { useProfileCard } from "./use-profile-card"
37
+ import type {
38
+ ProfileCardAction,
39
+ ProfileCardItem,
40
+ ProfileCardUser,
41
+ ProfileCardVariant,
42
+ } from "./type"
64
43
 
65
- /**
66
- * One entry of `items`. Authored flat; the block renders each run between
67
- * separators as its own menu group so a label actually names its section.
68
- */
69
- export type ProfileCardItem =
70
- ProfileCardAction | ProfileCardLabel | ProfileCardSeparator
44
+ export type {
45
+ ProfileCardAction,
46
+ ProfileCardActions,
47
+ ProfileCardInstanceId,
48
+ ProfileCardItem,
49
+ ProfileCardLabel,
50
+ ProfileCardSeparator,
51
+ ProfileCardState,
52
+ ProfileCardUser,
53
+ ProfileCardVariant,
54
+ } from "./type"
71
55
 
72
56
  /**
73
57
  * The menu the card ships with. Spread it to keep the shape and attach your
@@ -114,6 +98,31 @@ export interface ProfileCardBlockProps {
114
98
  * width, so `align` stops mattering horizontally.
115
99
  */
116
100
  menuWidth?: "content" | "card"
101
+ /**
102
+ * `expanded` shows avatar, name and handle; `collapsed` folds the card to
103
+ * its avatar — for an icon-collapsed sidebar rail. The fold animates: the
104
+ * identity lines and chevron slide to zero width, the avatar stays put.
105
+ * Controlled when passed. Left unset, the card follows the `AnimatedPanel`
106
+ * it sits in (a `SidebarBlock` footer folds with the rail on its own), and
107
+ * outside a panel starts from `defaultVariant`.
108
+ */
109
+ variant?: ProfileCardVariant
110
+ defaultVariant?: ProfileCardVariant
111
+ onVariantChange?: (variant: ProfileCardVariant) => void
112
+ /**
113
+ * Name this card's slice of the store so `useProfileCardState(id)` /
114
+ * `useProfileCardActions(id)` can read or drive it (fold it, open its
115
+ * menu) from anywhere under `ExegiaProvider`. Unnamed cards key off
116
+ * `useId` and are dropped on unmount.
117
+ */
118
+ profileCardId?: string
119
+ /**
120
+ * Presence badge on the avatar — overrides `user.presence`. Name the avatar
121
+ * with `avatarId` and `useUserAvatarActions(id).setPresence()` can flip it
122
+ * from a socket handler instead.
123
+ */
124
+ presence?: ProfileCardUser["presence"]
125
+ avatarId?: string
117
126
  /**
118
127
  * Emit cuelume press/release on the card and play the open/close cues.
119
128
  * Inert unless the app opts into interaction sound via `bindSounds()`.
@@ -164,6 +173,12 @@ export function ProfileCardBlock({
164
173
  side = "bottom",
165
174
  sideOffset = 8,
166
175
  menuWidth = "content",
176
+ variant: variantProp,
177
+ defaultVariant,
178
+ onVariantChange,
179
+ profileCardId,
180
+ presence,
181
+ avatarId,
167
182
  open,
168
183
  defaultOpen,
169
184
  onOpenChange,
@@ -171,81 +186,145 @@ export function ProfileCardBlock({
171
186
  onError,
172
187
  className,
173
188
  }: ProfileCardBlockProps): React.ReactElement {
174
- const [pending, setPending] = React.useState<string | null>(null)
175
- // Actions can outlive the card (sign-out unmounts it, typically).
176
- const mounted = React.useRef(true)
177
- React.useEffect(() => {
178
- mounted.current = true
179
- return () => {
180
- mounted.current = false
181
- }
182
- }, [])
183
-
184
189
  const groups = React.useMemo(() => toGroups(items), [items])
185
190
 
186
- function select(action: ProfileCardAction) {
187
- const result = action.onSelect?.()
188
- if (!(result instanceof Promise)) return
189
- setPending(action.id)
190
- result
191
- .catch((cause: unknown) => onError?.(cause))
192
- .finally(() => {
193
- if (mounted.current) setPending(null)
194
- })
195
- }
196
-
197
- const busy = pending !== null
191
+ // Optional, not required: the card is a standalone block that also lands
192
+ // in sidebar footers, so it reads the panel context when there is one and
193
+ // does not throw when there is not. Inside a panel the rail is the truth
194
+ // for the fold, so it is projected as a controlled variant.
195
+ const panel = React.useContext(AnimatedSidebarPanelContext)
196
+ const card = useProfileCard({
197
+ profileCardId,
198
+ variant:
199
+ variantProp ??
200
+ (panel ? (panel.collapsed ? "collapsed" : "expanded") : undefined),
201
+ defaultVariant,
202
+ onVariantChange,
203
+ open,
204
+ defaultOpen,
205
+ onError,
206
+ })
207
+ const { collapsed, busy, menuOpen, setMenuOpen, select } = card
208
+ const reduce = useReducedMotion()
209
+ const identity = `${user.name}${user.username ? ` ${user.username}` : ""}`
198
210
 
199
211
  const handleOpenChange: MenuPrimitive.Root.Props["onOpenChange"] = (
200
212
  nextOpen,
201
213
  details
202
214
  ) => {
203
215
  if (sound) playCue(nextOpen ? "bloom" : "droplet")
216
+ setMenuOpen(nextOpen)
204
217
  onOpenChange?.(nextOpen, details)
205
218
  }
206
219
 
207
220
  return (
208
- <DropdownMenu
209
- defaultOpen={defaultOpen}
210
- onOpenChange={handleOpenChange}
211
- open={open}
212
- >
221
+ // The store mirrors the menu: `open` (when passed) is projected into it,
222
+ // and this always renders from it, so `useProfileCardActions(id).openMenu()`
223
+ // works the same whether or not the consumer controls the prop.
224
+ <DropdownMenu onOpenChange={handleOpenChange} open={menuOpen}>
213
225
  <DropdownMenuTrigger
214
226
  disabled={busy}
215
227
  render={
216
228
  <Button
217
229
  // Spelled out rather than left to the name-from-contents rule: the
218
230
  // avatar sits inside the button and the handle is a second line.
219
- aria-label={`${user.name}${user.username ? ` ${user.username}` : ""}, account menu`}
231
+ aria-label={`${identity}, account menu`}
220
232
  className={cn(
221
233
  // Quiet at rest — the background only surfaces on hover or
222
234
  // while the menu is open.
223
235
  "h-auto w-full justify-between py-1.5 pl-1.5 data-popup-open:bg-accent sm:h-auto",
236
+ // Folded: a 40px avatar tile, the size the tree rail uses. It
237
+ // is 8px wider than a footer's 32px inner box on purpose — the
238
+ // -mx-1 lets it borrow the footer padding so the 32px avatar
239
+ // clears the 1px border and sits dead centre. The label spans
240
+ // below fold to zero width rather than unmounting so the change
241
+ // animates. Button's own gap-2 stays: its always-mounted loading
242
+ // slot carries a -ms-2 that cancels it, so zeroing the gap would
243
+ // pull the avatar 8px left; the folded chevron cancels its own
244
+ // gap instead (see below). `sm:size-10` because the base carries
245
+ // an `sm:h-auto` that an unprefixed size would not beat.
246
+ collapsed &&
247
+ "-mx-1 size-10 shrink-0 justify-center rounded-xl p-0 sm:size-10",
224
248
  className
225
249
  )}
250
+ data-collapsed={collapsed ? "" : undefined}
226
251
  data-slot="profile-card"
252
+ data-variant={collapsed ? "collapsed" : "expanded"}
227
253
  loading={busy}
228
254
  sound={sound}
255
+ // The visible name is gone while folded — name the tile on hover
256
+ // the way the rail's menu buttons do.
257
+ title={collapsed ? identity : undefined}
229
258
  variant="ghost"
230
259
  >
231
- <span className="flex w-full min-w-0 items-center justify-start gap-2">
260
+ <span
261
+ className={cn(
262
+ "flex min-w-0 items-center justify-start",
263
+ collapsed ? "w-auto gap-0" : "w-full gap-2"
264
+ )}
265
+ >
232
266
  <UserAvatar
233
267
  // Decorative: the name beside it already labels the card.
234
268
  alt=""
269
+ avatarId={avatarId}
235
270
  initials={user.initials}
236
271
  name={user.name}
272
+ presence={presence ?? user.presence}
237
273
  src={user.avatar}
238
274
  />
239
- <span className="flex min-w-0 flex-col text-left">
275
+ <motion.span
276
+ animate={{
277
+ width: collapsed ? 0 : "auto",
278
+ opacity: collapsed ? 0 : 1,
279
+ x: collapsed ? -4 : 0,
280
+ }}
281
+ aria-hidden={collapsed || undefined}
282
+ className={cn(
283
+ "flex min-w-0 flex-col overflow-hidden text-left whitespace-nowrap",
284
+ collapsed && "pointer-events-none"
285
+ )}
286
+ data-slot="profile-card-identity"
287
+ initial={false}
288
+ transition={
289
+ reduce
290
+ ? REDUCED_TRANSITION
291
+ : collapsed
292
+ ? LABEL_EXIT_TRANSITION
293
+ : LABEL_ENTER_TRANSITION
294
+ }
295
+ >
240
296
  <span className="truncate font-medium">{user.name}</span>
241
297
  {user.username ? (
242
298
  <span className="truncate text-xs font-normal text-muted-foreground">
243
299
  {user.username}
244
300
  </span>
245
301
  ) : null}
246
- </span>
302
+ </motion.span>
247
303
  </span>
248
- <ChevronsUpDownIcon aria-hidden="true" className="size-3.5" />
304
+ <motion.span
305
+ animate={{
306
+ width: collapsed ? 0 : "auto",
307
+ opacity: collapsed ? 0 : 1,
308
+ x: collapsed ? 4 : 0,
309
+ }}
310
+ aria-hidden="true"
311
+ className={cn(
312
+ "flex shrink-0 items-center overflow-hidden",
313
+ // 0px wide while folded, but the flex gap before it would
314
+ // still offset the avatar — pull it back by that gap.
315
+ collapsed && "-ml-2"
316
+ )}
317
+ initial={false}
318
+ transition={
319
+ reduce
320
+ ? REDUCED_TRANSITION
321
+ : collapsed
322
+ ? LABEL_EXIT_TRANSITION
323
+ : LABEL_ENTER_TRANSITION
324
+ }
325
+ >
326
+ <ChevronsUpDownIcon aria-hidden="true" className="size-3.5" />
327
+ </motion.span>
249
328
  </Button>
250
329
  }
251
330
  />
@@ -0,0 +1,95 @@
1
+ import type * as React from "react"
2
+
3
+ import type { UserPresence } from "@/components/user-avatar"
4
+
5
+ /** The identity shown on the card. */
6
+ export interface ProfileCardUser {
7
+ name: string
8
+ /** Secondary line under the name — a handle, an email, a role. */
9
+ username?: string
10
+ /** Avatar image URL. Falls back to the initials when absent or broken. */
11
+ avatar?: string
12
+ /** Fallback initials. Derived from `name` when omitted. */
13
+ initials?: string
14
+ /** Presence badge on the avatar. Omitted, no badge. */
15
+ presence?: UserPresence
16
+ }
17
+
18
+ /** An actionable row of the menu. */
19
+ export interface ProfileCardAction {
20
+ type?: "item"
21
+ id: string
22
+ label: string
23
+ icon?: React.ReactNode
24
+ /** Right-aligned hint, e.g. "⌘K". Purely decorative — bind the key yourself. */
25
+ shortcut?: string
26
+ disabled?: boolean
27
+ variant?: "default" | "destructive"
28
+ /** Returning a promise puts the card in its loading state until it settles. */
29
+ onSelect?: () => void | Promise<void>
30
+ }
31
+
32
+ /** A heading over the rows that follow it, up to the next separator. */
33
+ export interface ProfileCardLabel {
34
+ type: "label"
35
+ label: string
36
+ }
37
+
38
+ /** A rule between two sections. */
39
+ export interface ProfileCardSeparator {
40
+ type: "separator"
41
+ }
42
+
43
+ /**
44
+ * One entry of `items`. Authored flat; the block renders each run between
45
+ * separators as its own menu group so a label actually names its section.
46
+ */
47
+ export type ProfileCardItem =
48
+ | ProfileCardAction
49
+ | ProfileCardLabel
50
+ | ProfileCardSeparator
51
+
52
+ /** `expanded` shows avatar, name and handle; `collapsed` folds to the avatar
53
+ * alone — for an icon-collapsed sidebar rail. */
54
+ export type ProfileCardVariant = "expanded" | "collapsed"
55
+
56
+ /** Key for one card's state in the store. Any stable string; `useProfileCard`
57
+ * generates one when the component does not name itself. */
58
+ export type ProfileCardInstanceId = string
59
+
60
+ /** Everything the store knows about one card. */
61
+ export interface ProfileCardState {
62
+ variant: ProfileCardVariant
63
+ menuOpen: boolean
64
+ /** Id of the action whose promise is in flight, `null` while idle. */
65
+ pendingActionId: string | null
66
+ /** `pendingActionId !== null` — the trigger is disabled and shows a spinner. */
67
+ busy: boolean
68
+ }
69
+
70
+ /** Write-only handles onto one card. Nothing here re-renders the caller. */
71
+ export interface ProfileCardActions {
72
+ setVariant: (variant: ProfileCardVariant) => void
73
+ toggleVariant: () => void
74
+ /** Convenience over `setVariant`. */
75
+ collapse: () => void
76
+ expand: () => void
77
+ setMenuOpen: (open: boolean) => void
78
+ openMenu: () => void
79
+ closeMenu: () => void
80
+ reset: () => void
81
+ }
82
+
83
+ /** @internal Which fields the mounted component controls from props. The
84
+ * store must never overwrite a controlled value. */
85
+ export interface ProfileCardConfig {
86
+ controlsVariant: boolean
87
+ controlsMenuOpen: boolean
88
+ }
89
+
90
+ /** @internal Latest option callbacks. Only write atoms read this, so it can
91
+ * be refreshed every commit without re-rendering anything. */
92
+ export interface ProfileCardHandlers {
93
+ onVariantChange?: (variant: ProfileCardVariant) => void
94
+ onError?: (error: unknown) => void
95
+ }
@@ -0,0 +1,67 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
5
+
6
+ import {
7
+ profileCardStateAtom,
8
+ resetProfileCardAtom,
9
+ setProfileCardMenuOpenAtom,
10
+ setProfileCardVariantAtom,
11
+ toggleProfileCardVariantAtom,
12
+ } from "./profile-card-atom"
13
+ import type {
14
+ ProfileCardActions,
15
+ ProfileCardInstanceId,
16
+ ProfileCardState,
17
+ } from "./type"
18
+
19
+ /**
20
+ * Read the card registered under `profileCardId` from anywhere below
21
+ * `ExegiaProvider` — no props, no ref, no provider of its own.
22
+ *
23
+ * ```tsx
24
+ * const { variant, busy } = useProfileCardState("account")
25
+ * ```
26
+ *
27
+ * Returns the whole state object, so the caller re-renders on any change. A
28
+ * component that watches one field should subscribe to that atom instead:
29
+ * `useAtomValue(profileCardVariantAtom("account"))`.
30
+ */
31
+ export function useProfileCardState(
32
+ profileCardId: ProfileCardInstanceId
33
+ ): ProfileCardState {
34
+ return useAtomValue(profileCardStateAtom(profileCardId))
35
+ }
36
+
37
+ /**
38
+ * Write-only handles onto the card registered under `profileCardId`. Nothing
39
+ * here subscribes, so a rail toggle can fold the card without re-rendering
40
+ * when the card changes.
41
+ *
42
+ * ```tsx
43
+ * const { collapse, expand } = useProfileCardActions("account")
44
+ * ```
45
+ */
46
+ export function useProfileCardActions(
47
+ profileCardId: ProfileCardInstanceId
48
+ ): ProfileCardActions {
49
+ const setVariant = useSetAtom(setProfileCardVariantAtom(profileCardId))
50
+ const toggleVariant = useSetAtom(toggleProfileCardVariantAtom(profileCardId))
51
+ const setMenuOpen = useSetAtom(setProfileCardMenuOpenAtom(profileCardId))
52
+ const reset = useSetAtom(resetProfileCardAtom(profileCardId))
53
+
54
+ return React.useMemo(
55
+ () => ({
56
+ setVariant,
57
+ toggleVariant,
58
+ collapse: () => setVariant("collapsed"),
59
+ expand: () => setVariant("expanded"),
60
+ setMenuOpen,
61
+ openMenu: () => setMenuOpen(true),
62
+ closeMenu: () => setMenuOpen(false),
63
+ reset,
64
+ }),
65
+ [setVariant, toggleVariant, setMenuOpen, reset]
66
+ )
67
+ }
@@ -0,0 +1,126 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
5
+
6
+ import {
7
+ profileCardBusyAtom,
8
+ profileCardMenuOpenAtom,
9
+ profileCardVariantAtom,
10
+ projectProfileCardPropsAtom,
11
+ removeProfileCardInstance,
12
+ seedProfileCardVariantAtom,
13
+ selectProfileCardActionAtom,
14
+ setProfileCardHandlersAtom,
15
+ setProfileCardMenuOpenAtom,
16
+ } from "./profile-card-atom"
17
+ import type {
18
+ ProfileCardAction,
19
+ ProfileCardConfig,
20
+ ProfileCardHandlers,
21
+ ProfileCardInstanceId,
22
+ ProfileCardVariant,
23
+ } from "./type"
24
+
25
+ export interface UseProfileCardOptions {
26
+ profileCardId?: ProfileCardInstanceId
27
+ /** Controlled fold. `undefined` leaves the store (and `defaultVariant`) in charge. */
28
+ variant?: ProfileCardVariant
29
+ defaultVariant?: ProfileCardVariant
30
+ onVariantChange?: (variant: ProfileCardVariant) => void
31
+ /** Controlled menu. */
32
+ open?: boolean
33
+ defaultOpen?: boolean
34
+ onError?: (error: unknown) => void
35
+ }
36
+
37
+ export interface ProfileCardBinding {
38
+ profileCardId: ProfileCardInstanceId
39
+ variant: ProfileCardVariant
40
+ collapsed: boolean
41
+ menuOpen: boolean
42
+ busy: boolean
43
+ /** Hand to the menu's `onOpenChange` (first argument). Stable. */
44
+ setMenuOpen: (open: boolean) => void
45
+ /** Run an action: `onSelect`, promise → busy, rejection → `onError`. Stable. */
46
+ select: (action: ProfileCardAction) => void
47
+ }
48
+
49
+ /**
50
+ * Binds one profile card to its slice of the store: projects the controlled
51
+ * `variant` / `open` props (one-way, with write gates so the store never
52
+ * overwrites a prop), publishes the latest handlers, and returns the values
53
+ * the component renders from.
54
+ *
55
+ * Unnamed cards key off `useId` and are dropped on unmount; a named
56
+ * `profileCardId` outlives its component, so a fold set from elsewhere
57
+ * (`useProfileCardActions(id).collapse()`) survives a route change.
58
+ */
59
+ export function useProfileCard(options: UseProfileCardOptions): ProfileCardBinding {
60
+ const {
61
+ variant: variantProp,
62
+ defaultVariant = "expanded",
63
+ onVariantChange,
64
+ open: openProp,
65
+ onError,
66
+ } = options
67
+ const generatedId = React.useId()
68
+ const profileCardId = options.profileCardId ?? generatedId
69
+
70
+ const controlsVariant = variantProp !== undefined
71
+ const controlsMenuOpen = openProp !== undefined
72
+ const config = React.useMemo<ProfileCardConfig>(
73
+ () => ({ controlsVariant, controlsMenuOpen }),
74
+ [controlsVariant, controlsMenuOpen]
75
+ )
76
+ const handlers = React.useMemo<ProfileCardHandlers>(
77
+ () => ({ onVariantChange, onError }),
78
+ [onVariantChange, onError]
79
+ )
80
+
81
+ const project = useSetAtom(projectProfileCardPropsAtom(profileCardId))
82
+ const publishHandlers = useSetAtom(setProfileCardHandlersAtom(profileCardId))
83
+ const seedVariant = useSetAtom(seedProfileCardVariantAtom(profileCardId))
84
+ const setMenuOpen = useSetAtom(setProfileCardMenuOpenAtom(profileCardId))
85
+ const select = useSetAtom(selectProfileCardActionAtom(profileCardId))
86
+
87
+ // Seed the uncontrolled fold once per mount, before the projection — the
88
+ // `default*` options describe the mount, not every render. Layout effects
89
+ // so children read settled values in the same commit; primitive deps, so
90
+ // no loop guard is needed.
91
+ const [seed] = React.useState(() => ({
92
+ variant: variantProp ?? defaultVariant,
93
+ open: openProp ?? options.defaultOpen ?? false,
94
+ }))
95
+ React.useLayoutEffect(() => {
96
+ if (!controlsVariant) seedVariant(seed.variant)
97
+ if (!controlsMenuOpen) setMenuOpen(seed.open)
98
+ // Mount-time seed only.
99
+ // eslint-disable-next-line react-hooks/exhaustive-deps
100
+ }, [])
101
+ React.useLayoutEffect(() => {
102
+ project(config, variantProp, openProp)
103
+ }, [project, config, variantProp, openProp])
104
+ React.useLayoutEffect(() => {
105
+ publishHandlers(handlers)
106
+ }, [publishHandlers, handlers])
107
+
108
+ React.useEffect(() => {
109
+ if (options.profileCardId !== undefined) return
110
+ return () => removeProfileCardInstance(profileCardId)
111
+ }, [options.profileCardId, profileCardId])
112
+
113
+ const variant = useAtomValue(profileCardVariantAtom(profileCardId))
114
+ const menuOpen = useAtomValue(profileCardMenuOpenAtom(profileCardId))
115
+ const busy = useAtomValue(profileCardBusyAtom(profileCardId))
116
+
117
+ return {
118
+ profileCardId,
119
+ variant,
120
+ collapsed: variant === "collapsed",
121
+ menuOpen,
122
+ busy,
123
+ setMenuOpen,
124
+ select,
125
+ }
126
+ }