@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
@@ -0,0 +1,152 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { Avatar, AvatarImage } from "@/components/ui/avatar"
6
+ import { cn } from "@/lib/utils"
7
+ import { Fallback } from "./fallback"
8
+ import { PresenceBadge } from "./presence-badge"
9
+ import type { UserAvatarProps } from "./type"
10
+ import { useUserAvatar } from "./use-user-avatar"
11
+
12
+ /**
13
+ * Identity avatar: an image when one is given, initials otherwise, with an
14
+ * optional presence badge and a pointer-lit bezel.
15
+ *
16
+ * A passed `src` is assumed to be remote, so the slot holds a skeleton until
17
+ * the image resolves rather than flashing initials that are about to be
18
+ * replaced. A failed load settles on the initials.
19
+ *
20
+ * State (presence, bezel angle, image status) lives in Jotai atoms keyed by
21
+ * `avatarId` — name the avatar and `useUserAvatarActions(id).setPresence()`
22
+ * flips its badge from anywhere under `ExegiaProvider`.
23
+ */
24
+ /** The rim (and the badge sheen) trail the pointer's bearing slightly rather
25
+ * than pointing dead at it — a touch of lag reads as weight. */
26
+ const BEZEL_DAMPING = 0.8
27
+
28
+ export function UserAvatar({
29
+ src,
30
+ name = "",
31
+ initials,
32
+ alt,
33
+ loading: loadingProp,
34
+ presence: presenceProp,
35
+ bezel = true,
36
+ avatarId,
37
+ className,
38
+ style,
39
+ ...props
40
+ }: UserAvatarProps): React.ReactElement {
41
+ const hasImage = src !== undefined
42
+ const avatar = useUserAvatar({
43
+ avatarId,
44
+ presence: presenceProp,
45
+ bezel,
46
+ hasImage,
47
+ })
48
+ const { presence, bezelAngle, imageStatus, setImageStatus, ref } = avatar
49
+
50
+ const loading =
51
+ loadingProp ??
52
+ (hasImage && (imageStatus === "idle" || imageStatus === "loading"))
53
+ // A photo is actually on screen — not merely requested. A broken or
54
+ // still-loading `src` shows initials/skeleton and should be treated as
55
+ // the flat disc it is.
56
+ const showsImage = hasImage && loadingProp !== true && imageStatus === "loaded"
57
+
58
+ return (
59
+ // The frame is what the badge and bezel hang off: the Avatar root clips
60
+ // to its circle, and the badge has to sit on the rim, half outside it.
61
+ // Sizing classes still target the Avatar — the frame shrink-wraps it.
62
+ <span
63
+ className="relative inline-flex shrink-0 rounded-full"
64
+ data-slot="user-avatar-frame"
65
+ ref={ref as React.RefObject<HTMLSpanElement>}
66
+ >
67
+ <Avatar
68
+ className={cn(
69
+ "size-8 border-1 border-white bg-neutral-800! text-xl dark:border-neutral-900",
70
+ // The emboss itself: an inset drop toward the light's opposite side
71
+ // is what the ring below is rotated against.
72
+ bezel &&
73
+ "shadow-[inset_0_3px_3px_--theme(--color-white/80%),inset_0_-1px_2px_--theme(--color-black/15%)] dark:shadow-[inset_0_1px_1px_--theme(--color-white/5%),inset_0_-8px_2px_--theme(--color-black/20%)]",
74
+ className
75
+ )}
76
+ data-avatar-id={avatar.avatarId}
77
+ data-bezel={bezel ? "" : undefined}
78
+ data-loading={loading || undefined}
79
+ data-presence={presence ?? undefined}
80
+ data-slot="user-avatar"
81
+ style={style}
82
+ {...props}
83
+ >
84
+ {/* Held back while forced-loading: a loaded image would hide the
85
+ fallback the skeleton lives in. */}
86
+ {!hasImage || loadingProp === true ? null : (
87
+ <AvatarImage
88
+ alt={alt ?? name}
89
+ onLoadingStatusChange={setImageStatus}
90
+ src={src}
91
+ />
92
+ )}
93
+ <Fallback name={name} initials={initials} loading={loading} />
94
+ </Avatar>
95
+ {presence === "online" ? (
96
+ // The badge's green "reflected" onto the disc: a static radial wash
97
+ // anchored at the badge corner that fades inward, so the dot reads
98
+ // as a light source sitting on the avatar rather than a sticker on
99
+ // it. Does not move with the bezel — it belongs to the badge, not
100
+ // the pointer. Sits above the image, below the bezel and the badge.
101
+ <span
102
+ aria-hidden="true"
103
+ className={cn(
104
+ "pointer-events-none absolute inset-0 rounded-full",
105
+ // One gradient, four knobs: colour, peak alpha, mid alpha, reach.
106
+ // Light mode is quieter than dark (a green wash on a pale disc
107
+ // reads as a stain, on a dark one as a glow), and an initials
108
+ // disc — flat, no photo to sit on — takes a smaller, fainter
109
+ // pool than an image does.
110
+ "[--reflect:--theme(--color-emerald-500)] dark:[--reflect:--theme(--color-emerald-400)]",
111
+ showsImage
112
+ ? "[--reflect-peak:30%] [--reflect-mid:12%] [--reflect-reach:56%] dark:[--reflect-peak:40%] dark:[--reflect-mid:16%] dark:[--reflect-reach:60%]"
113
+ : "[--reflect-peak:16%] [--reflect-mid:6%] [--reflect-reach:40%] dark:[--reflect-peak:26%] dark:[--reflect-mid:9%] dark:[--reflect-reach:46%]",
114
+ "bg-[radial-gradient(circle_at_88%_88%,color-mix(in_oklab,var(--reflect)_var(--reflect-peak),transparent)_0%,color-mix(in_oklab,var(--reflect)_var(--reflect-mid),transparent)_22%,transparent_var(--reflect-reach))]",
115
+ "transition-opacity duration-150 ease-smooth-out"
116
+ )}
117
+ data-image={showsImage ? "" : undefined}
118
+ data-slot="user-avatar-reflection"
119
+ />
120
+ ) : null}
121
+ {bezel ? (
122
+ // A conic highlight → shadow sweep, masked to a thin rim and rotated
123
+ // so the highlight faces the pointer. Rotation (not the gradient
124
+ // angle) is what moves, because `transform` transitions and a
125
+ // gradient stop does not. Light and dark pick their own alphas via
126
+ // the two CSS vars; the gradient reads them.
127
+ <span
128
+ aria-hidden="true"
129
+ className={cn(
130
+ "pointer-events-none absolute inset-0 rounded-full",
131
+ "[--bezel-hi:--theme(--color-white)] [--bezel-lo:--theme(--color-black/20%)]",
132
+ "dark:[--bezel-hi:--theme(--color-white/18%)] dark:[--bezel-lo:--theme(--color-black/40%)]",
133
+ // Starts at 6 o'clock: shadow there, highlight peaking at 12 (50%),
134
+ // symmetric either side — so rotate(angle) puts the highlight at
135
+ // exactly `bezelAngle` clockwise from 12.
136
+ "bg-[conic-gradient(from_180deg,var(--bezel-lo)_0%,transparent_24%,transparent_28%,var(--bezel-hi)_50%,transparent_72%,transparent_76%,var(--bezel-lo)_100%)]",
137
+ "[mask-image:radial-gradient(farthest-side,transparent_calc(95%-4px),#000_calc(100%-2px))]",
138
+ "mix-blend-normal transition-transform duration-150 ease-smooth-out motion-reduce:transition-none"
139
+ )}
140
+ data-slot="user-avatar-bezel"
141
+ style={{ transform: `rotate(${bezelAngle * BEZEL_DAMPING}deg)` }}
142
+ />
143
+ ) : null}
144
+ {presence ? (
145
+ <PresenceBadge
146
+ lightAngle={bezel ? bezelAngle * BEZEL_DAMPING : undefined}
147
+ presence={presence}
148
+ />
149
+ ) : null}
150
+ </span>
151
+ )
152
+ }
@@ -0,0 +1,26 @@
1
+ import { AvatarFallback } from "@/components/ui/avatar"
2
+ import { Skeleton } from "@/components/ui/skeleton"
3
+ import { initialsFrom } from "./utils"
4
+
5
+ export function Fallback({
6
+ name,
7
+ initials,
8
+ loading,
9
+ }: {
10
+ name?: string
11
+ initials?: string
12
+ loading?: boolean
13
+ }) {
14
+ return (
15
+ <AvatarFallback>
16
+ {loading ? (
17
+ <Skeleton
18
+ className="size-full rounded-full"
19
+ data-slot="avatar-skeleton"
20
+ />
21
+ ) : (
22
+ (initials ?? initialsFrom(name ?? ""))
23
+ )}
24
+ </AvatarFallback>
25
+ )
26
+ }
@@ -0,0 +1,34 @@
1
+ import { AudioWave } from "./audio-wave"
2
+ import { UserAvatar } from "./component"
3
+ import { Fallback } from "./fallback"
4
+
5
+ export { AudioWave } from "./audio-wave"
6
+ export { UserAvatar } from "./component"
7
+ export { Fallback } from "./fallback"
8
+ export { PresenceBadge } from "./presence-badge"
9
+ export { initialsFrom } from "./utils"
10
+ export { useUserAvatar } from "./use-user-avatar"
11
+ export type { UseUserAvatarOptions, UserAvatarBinding } from "./use-user-avatar"
12
+ export { useUserAvatarActions, useUserAvatarState } from "./use-user-avatar-state"
13
+ // The public atom surface. `@internal` atoms (config, image status, the
14
+ // props projection) stay unexported on purpose — `export *` would make every
15
+ // internal a breaking-change surface for consumer apps.
16
+ export {
17
+ DEFAULT_BEZEL_ANGLE,
18
+ removeUserAvatarInstance,
19
+ resetUserAvatarAtom,
20
+ setUserAvatarBezelAngleAtom,
21
+ setUserAvatarPresenceAtom,
22
+ toggleUserAvatarPresenceAtom,
23
+ userAvatarBezelAngleAtom,
24
+ userAvatarIsOnlineAtom,
25
+ userAvatarPresenceAtom,
26
+ userAvatarStateAtom,
27
+ } from "./user-avatar-atom"
28
+ export type * from "./type"
29
+
30
+ export const Avatar = {
31
+ AudioWave,
32
+ Component: UserAvatar,
33
+ Fallback,
34
+ }
@@ -0,0 +1,56 @@
1
+ import { cn } from "@/lib/utils"
2
+ import type { UserPresence } from "./type"
3
+
4
+ /**
5
+ * Corner dot naming whether the person is reachable. Sits on the avatar
6
+ * frame, outside the clipped circle, with a background-coloured ring so it
7
+ * reads against any image. Colour is not the only signal: `offline` is a
8
+ * hollow ring, `online` a filled dot, and the label is spelled out for AT.
9
+ *
10
+ * `lightAngle` (degrees clockwise from 12) lays a faint sheen across the dot
11
+ * from that side — the same angle the avatar's bezel is lit from, so the two
12
+ * highlights agree and the badge reads as a bead sitting in the same light.
13
+ */
14
+ export function PresenceBadge({
15
+ presence,
16
+ lightAngle,
17
+ className,
18
+ }: {
19
+ presence: UserPresence
20
+ lightAngle?: number
21
+ className?: string
22
+ }) {
23
+ const online = presence === "online"
24
+ return (
25
+ <span
26
+ aria-label={online ? "Online" : "Offline"}
27
+ className={cn(
28
+ "absolute right-0 bottom-0 box-content size-[22%] min-h-1.5 min-w-1.5 overflow-hidden rounded-full",
29
+ "ring-2 ring-background transition-colors duration-150 ease-smooth-out",
30
+ online
31
+ ? "bg-emerald-500 dark:bg-emerald-400"
32
+ : "border-2 border-muted-foreground/60 bg-background dark:border-muted-foreground/70",
33
+ className
34
+ )}
35
+ data-presence={presence}
36
+ data-slot="user-avatar-presence"
37
+ role="img"
38
+ >
39
+ {lightAngle !== undefined ? (
40
+ // Highlight at 12 o'clock, fading out by the middle, rotated so it
41
+ // faces the light. Rotation transitions; the gradient is static.
42
+ <span
43
+ aria-hidden="true"
44
+ className={cn(
45
+ "pointer-events-none absolute inset-0 rounded-full",
46
+ "bg-[linear-gradient(to_bottom,--theme(--color-white/55%),--theme(--color-white/12%)_38%,transparent_58%)]",
47
+ "dark:bg-[linear-gradient(to_bottom,--theme(--color-white/40%),--theme(--color-white/8%)_38%,transparent_58%)]",
48
+ "transition-transform duration-150 ease-smooth-out motion-reduce:transition-none"
49
+ )}
50
+ data-slot="user-avatar-presence-sheen"
51
+ style={{ transform: `rotate(${lightAngle}deg)` }}
52
+ />
53
+ ) : null}
54
+ </span>
55
+ )
56
+ }
@@ -0,0 +1,85 @@
1
+ import type * as React from "react"
2
+
3
+ import type { AvatarPrimitive } from "@/components/ui/avatar"
4
+
5
+ export type ImageStatus = Parameters<
6
+ NonNullable<AvatarPrimitive.Image.Props["onLoadingStatusChange"]>
7
+ >[0]
8
+
9
+ /** Whether the person is reachable right now — drawn as the corner badge. */
10
+ export type UserPresence = "online" | "offline"
11
+
12
+ /** Key for one avatar's state in the store. Any stable string; `useUserAvatar`
13
+ * generates one when the component does not name itself. */
14
+ export type UserAvatarInstanceId = string
15
+
16
+ export interface UserAvatarProps extends Omit<
17
+ AvatarPrimitive.Root.Props,
18
+ "children"
19
+ > {
20
+ /** Image URL. Without one the initials show immediately — no skeleton. */
21
+ src?: string
22
+ /** Drives the initials, and the alt text unless `alt` overrides it. */
23
+ name?: string
24
+ /** Overrides the initials derived from `name`. */
25
+ initials?: string
26
+ /**
27
+ * Alt text for the image. Pass "" when adjacent text already names the
28
+ * person — the image is then decorative.
29
+ */
30
+ alt?: string
31
+ /**
32
+ * Force the skeleton, for when the identity itself is still being fetched.
33
+ * Omitted, it follows the image: a passed `src` skeletons until it resolves.
34
+ */
35
+ loading?: boolean
36
+ /**
37
+ * Corner badge: a green dot for `online`, a hollow grey ring for `offline`.
38
+ * Omitted, no badge — unless the avatar is driven by id and something set
39
+ * its presence in the store. Controlled when passed.
40
+ */
41
+ presence?: UserPresence
42
+ /**
43
+ * Embossed rim whose highlight follows the pointer, so the avatar reads as
44
+ * a lit bezel rather than a flat disc. Light and dark aware; static under
45
+ * reduced motion. On by default — pass `false` for a flat disc.
46
+ */
47
+ bezel?: boolean
48
+ /**
49
+ * Name this avatar's slice of the store so `useUserAvatarState(id)` /
50
+ * `useUserAvatarActions(id)` can read or drive it from anywhere under
51
+ * `ExegiaProvider`. Unnamed avatars key off `useId` and are dropped on
52
+ * unmount.
53
+ */
54
+ avatarId?: UserAvatarInstanceId
55
+ }
56
+
57
+ /** Everything the store knows about one avatar. */
58
+ export interface UserAvatarState {
59
+ presence: UserPresence | null
60
+ /** Bezel light direction in degrees, clockwise from 12 o'clock. Continuous
61
+ * (may leave [0, 360)) so the rim's CSS rotation never spins the long way;
62
+ * `((a % 360) + 360) % 360` if you need it normalised. */
63
+ bezelAngle: number
64
+ imageStatus: ImageStatus
65
+ }
66
+
67
+ /** Write-only handles onto one avatar. Nothing here re-renders the caller. */
68
+ export interface UserAvatarActions {
69
+ setPresence: (presence: UserPresence | null) => void
70
+ togglePresence: () => void
71
+ setBezelAngle: (angle: number) => void
72
+ reset: () => void
73
+ }
74
+
75
+ /** @internal Which fields the mounted component controls from props. The
76
+ * store must never overwrite a controlled value. */
77
+ export interface UserAvatarConfig {
78
+ controlsPresence: boolean
79
+ }
80
+
81
+ export interface IAudioWaveProps {
82
+ className?: string
83
+ children?: React.ReactNode
84
+ volume?: number
85
+ }
@@ -0,0 +1,60 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
5
+
6
+ import type {
7
+ UserAvatarActions,
8
+ UserAvatarInstanceId,
9
+ UserAvatarState,
10
+ } from "./type"
11
+ import {
12
+ resetUserAvatarAtom,
13
+ setUserAvatarBezelAngleAtom,
14
+ setUserAvatarPresenceAtom,
15
+ toggleUserAvatarPresenceAtom,
16
+ userAvatarStateAtom,
17
+ } from "./user-avatar-atom"
18
+
19
+ /**
20
+ * Read the avatar registered under `avatarId` from anywhere below
21
+ * `ExegiaProvider` — no props, no ref, no provider of its own.
22
+ *
23
+ * ```tsx
24
+ * const { presence } = useUserAvatarState("me")
25
+ * ```
26
+ *
27
+ * Returns the whole state object, so the caller re-renders on any change —
28
+ * including every bezel frame while a pointer moves. A component that only
29
+ * cares about presence should subscribe to that atom instead:
30
+ * `useAtomValue(userAvatarPresenceAtom("me"))`.
31
+ */
32
+ export function useUserAvatarState(
33
+ avatarId: UserAvatarInstanceId
34
+ ): UserAvatarState {
35
+ return useAtomValue(userAvatarStateAtom(avatarId))
36
+ }
37
+
38
+ /**
39
+ * Write-only handles onto the avatar registered under `avatarId`. Nothing
40
+ * here subscribes, so a presence indicator elsewhere in the app can flip
41
+ * the badge without re-rendering when the avatar changes.
42
+ *
43
+ * ```tsx
44
+ * const { setPresence } = useUserAvatarActions("me")
45
+ * socket.on("presence", (p) => setPresence(p))
46
+ * ```
47
+ */
48
+ export function useUserAvatarActions(
49
+ avatarId: UserAvatarInstanceId
50
+ ): UserAvatarActions {
51
+ const setPresence = useSetAtom(setUserAvatarPresenceAtom(avatarId))
52
+ const togglePresence = useSetAtom(toggleUserAvatarPresenceAtom(avatarId))
53
+ const setBezelAngle = useSetAtom(setUserAvatarBezelAngleAtom(avatarId))
54
+ const reset = useSetAtom(resetUserAvatarAtom(avatarId))
55
+
56
+ return React.useMemo(
57
+ () => ({ setPresence, togglePresence, setBezelAngle, reset }),
58
+ [setPresence, togglePresence, setBezelAngle, reset]
59
+ )
60
+ }
@@ -0,0 +1,144 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
5
+ import { useReducedMotion } from "motion/react"
6
+
7
+ import type {
8
+ ImageStatus,
9
+ UserAvatarConfig,
10
+ UserAvatarInstanceId,
11
+ UserPresence,
12
+ } from "./type"
13
+ import {
14
+ projectUserAvatarPropsAtom,
15
+ removeUserAvatarInstance,
16
+ setUserAvatarBezelAngleAtom,
17
+ setUserAvatarImageStatusAtom,
18
+ userAvatarBezelAngleAtom,
19
+ userAvatarImageStatusAtom,
20
+ userAvatarPresenceAtom,
21
+ } from "./user-avatar-atom"
22
+
23
+ export interface UseUserAvatarOptions {
24
+ avatarId?: UserAvatarInstanceId
25
+ /** Controlled presence. `undefined` leaves the store in charge. */
26
+ presence?: UserPresence
27
+ /** Track the pointer for the bezel highlight. */
28
+ bezel: boolean
29
+ /** Only meaningful with an image — no image, no status to track. */
30
+ hasImage: boolean
31
+ }
32
+
33
+ export interface UserAvatarBinding {
34
+ avatarId: UserAvatarInstanceId
35
+ presence: UserPresence | null
36
+ bezelAngle: number
37
+ imageStatus: ImageStatus
38
+ /** Hand to the image's `onLoadingStatusChange`. Stable. */
39
+ setImageStatus: (status: ImageStatus) => void
40
+ /** Attach to the avatar root — the bezel is lit relative to its centre. */
41
+ ref: React.RefObject<HTMLElement | null>
42
+ }
43
+
44
+ /** Below this the angle write is skipped: a rim highlight cannot show a
45
+ * sub-degree change, and skipping keeps the pointer loop store-quiet. */
46
+ const ANGLE_EPSILON = 1.5
47
+
48
+ /**
49
+ * Binds one avatar to its slice of the store: projects the controlled
50
+ * `presence` prop, tracks the image status, and — while `bezel` is on —
51
+ * follows the pointer with a rAF-coalesced `pointermove` listener that turns
52
+ * the pointer's bearing from the avatar's centre into `bezelAngle`.
53
+ *
54
+ * Unnamed avatars key off `useId` and are dropped from the store on unmount;
55
+ * a named `avatarId` outlives its component so a presence set elsewhere
56
+ * survives a remount.
57
+ */
58
+ export function useUserAvatar(options: UseUserAvatarOptions): UserAvatarBinding {
59
+ const { presence: presenceProp, bezel, hasImage } = options
60
+ const generatedId = React.useId()
61
+ const avatarId = options.avatarId ?? generatedId
62
+ const controlsPresence = presenceProp !== undefined
63
+
64
+ const config = React.useMemo<UserAvatarConfig>(
65
+ () => ({ controlsPresence }),
66
+ [controlsPresence]
67
+ )
68
+
69
+ const project = useSetAtom(projectUserAvatarPropsAtom(avatarId))
70
+ const setBezelAngle = useSetAtom(setUserAvatarBezelAngleAtom(avatarId))
71
+ const setImageStatus = useSetAtom(setUserAvatarImageStatusAtom(avatarId))
72
+
73
+ // Layout effect: the projection must land before children read the store
74
+ // in the same commit. Primitive deps, so no loop guard is needed here.
75
+ React.useLayoutEffect(() => {
76
+ project(config, presenceProp)
77
+ }, [project, config, presenceProp])
78
+
79
+ // A generated key belongs to this mount only; a given `avatarId` is the
80
+ // app's handle and outlives it.
81
+ React.useEffect(() => {
82
+ if (options.avatarId !== undefined) return
83
+ return () => removeUserAvatarInstance(avatarId)
84
+ }, [options.avatarId, avatarId])
85
+
86
+ const presence = useAtomValue(userAvatarPresenceAtom(avatarId))
87
+ const bezelAngle = useAtomValue(userAvatarBezelAngleAtom(avatarId))
88
+ const imageStatus = useAtomValue(userAvatarImageStatusAtom(avatarId))
89
+
90
+ const ref = React.useRef<HTMLElement | null>(null)
91
+ const reduce = useReducedMotion()
92
+
93
+ // Pointer → bearing. Coalesced to one write per animation frame, and only
94
+ // when the angle moved enough to matter, so a room full of avatars is
95
+ // still one cheap listener each and near-zero store traffic while idle.
96
+ React.useEffect(() => {
97
+ if (!bezel || reduce || typeof window === "undefined") return
98
+ let frame = 0
99
+ let last = Number.NaN
100
+ let pointer: { x: number; y: number } | null = null
101
+
102
+ const flush = () => {
103
+ frame = 0
104
+ const host = ref.current
105
+ if (!host || !pointer) return
106
+ const rect = host.getBoundingClientRect()
107
+ if (rect.width === 0) return
108
+ const dx = pointer.x - (rect.left + rect.width / 2)
109
+ const dy = pointer.y - (rect.top + rect.height / 2)
110
+ // atan2 measures from 3 o'clock anticlockwise-positive in screen
111
+ // space; rotate so 0 is 12 o'clock and clockwise grows.
112
+ const bearing = ((Math.atan2(dy, dx) * 180) / Math.PI + 90 + 360) % 360
113
+ // Unwrap: step from the last value by the shortest arc, so the stored
114
+ // angle is continuous and the rim never spins the long way round.
115
+ let angle = bearing
116
+ if (!Number.isNaN(last)) {
117
+ let delta = bearing - (((last % 360) + 360) % 360)
118
+ if (delta > 180) delta -= 360
119
+ if (delta < -180) delta += 360
120
+ if (Math.abs(delta) < ANGLE_EPSILON) return
121
+ angle = last + delta
122
+ }
123
+ last = angle
124
+ setBezelAngle(angle)
125
+ }
126
+ const onMove = (event: PointerEvent) => {
127
+ pointer = { x: event.clientX, y: event.clientY }
128
+ if (frame === 0) frame = window.requestAnimationFrame(flush)
129
+ }
130
+
131
+ window.addEventListener("pointermove", onMove, { passive: true })
132
+ return () => {
133
+ window.removeEventListener("pointermove", onMove)
134
+ if (frame !== 0) window.cancelAnimationFrame(frame)
135
+ }
136
+ }, [bezel, reduce, setBezelAngle])
137
+
138
+ // No image, no status: keep the store honest when `src` goes away.
139
+ React.useEffect(() => {
140
+ if (!hasImage) setImageStatus("idle")
141
+ }, [hasImage, setImageStatus])
142
+
143
+ return { avatarId, presence, bezelAngle, imageStatus, setImageStatus, ref }
144
+ }