@exegia/corpora-ui 0.20.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 (123) 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/ai-sidebar-atom.d.ts +217 -0
  9. package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
  10. package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
  11. package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
  12. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +109 -29
  13. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
  14. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +6 -1
  15. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +1 -1
  16. package/dist-lib/components/blocks/profile/index.d.ts +7 -0
  17. package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
  18. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
  19. package/dist-lib/components/blocks/profile/type.d.ts +80 -0
  20. package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
  21. package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
  22. package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
  23. package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
  24. package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
  25. package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
  26. package/dist-lib/components/blocks/shell/index.d.ts +4 -0
  27. package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
  28. package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
  29. package/dist-lib/components/blocks/shell/type.d.ts +115 -3
  30. package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
  31. package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
  32. package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
  33. package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
  34. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  35. package/dist-lib/components/composed/tree/constants.d.ts +4 -4
  36. package/dist-lib/components/composed/tree/index.d.ts +3 -1
  37. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  38. package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
  39. package/dist-lib/components/composed/tree/type.d.ts +116 -18
  40. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
  41. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  42. package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
  43. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  44. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  45. package/dist-lib/components/user-avatar/component.d.ts +1 -8
  46. package/dist-lib/components/user-avatar/index.d.ts +5 -0
  47. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  48. package/dist-lib/components/user-avatar/type.d.ts +45 -0
  49. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  50. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
  51. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
  52. package/dist-lib/index.d.ts +5 -2
  53. package/dist-lib/index.js +3004 -1796
  54. package/dist-lib/index.js.map +1 -1
  55. package/dist-lib/state/exegia-provider.d.ts +51 -0
  56. package/dist-lib/state/index.d.ts +4 -0
  57. package/dist-lib/state/store.d.ts +17 -0
  58. package/package.json +15 -12
  59. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  60. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  61. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  62. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  63. package/src/components/blocks/auth/auth-state.ts +52 -0
  64. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  65. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  66. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  67. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
  68. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  69. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  70. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  71. package/src/components/blocks/nav/sidebar/type.ts +125 -30
  72. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  73. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
  74. package/src/components/blocks/nav/sidebar/utils.ts +1 -1
  75. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  76. package/src/components/blocks/profile/index.ts +28 -0
  77. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  78. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  79. package/src/components/blocks/profile/type.ts +95 -0
  80. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  81. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  82. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  83. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  84. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  85. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  86. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  87. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  88. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  89. package/src/components/blocks/shell/index.ts +18 -0
  90. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  91. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  92. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  93. package/src/components/blocks/shell/type.ts +130 -3
  94. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  95. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  96. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  97. package/src/components/blocks/shell/utils.ts +44 -4
  98. package/src/components/composed/tree/CLAUDE.md +132 -0
  99. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  100. package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
  101. package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
  102. package/src/components/composed/tree/constants.ts +4 -4
  103. package/src/components/composed/tree/index.ts +38 -0
  104. package/src/components/composed/tree/tree-atom.ts +590 -0
  105. package/src/components/composed/tree/tree-node.tsx +117 -78
  106. package/src/components/composed/tree/tree.tsx +24 -16
  107. package/src/components/composed/tree/type.ts +125 -15
  108. package/src/components/composed/tree/use-tree-dnd.ts +82 -51
  109. package/src/components/composed/tree/use-tree-state.ts +105 -0
  110. package/src/components/composed/tree/use-tree.ts +185 -184
  111. package/src/components/composed/user-avatar.tsx +11 -99
  112. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
  113. package/src/components/user-avatar/component.tsx +121 -22
  114. package/src/components/user-avatar/index.ts +19 -0
  115. package/src/components/user-avatar/presence-badge.tsx +56 -0
  116. package/src/components/user-avatar/type.ts +50 -0
  117. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  118. package/src/components/user-avatar/use-user-avatar.ts +144 -0
  119. package/src/components/user-avatar/user-avatar-atom.ts +198 -0
  120. package/src/index.ts +34 -2
  121. package/src/state/exegia-provider.tsx +79 -0
  122. package/src/state/index.ts +4 -0
  123. package/src/state/store.ts +19 -0
@@ -0,0 +1,245 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { act, fireEvent, render, screen } from "@testing-library/react"
3
+ import { createStore, Provider } from "jotai"
4
+
5
+ import { UserAvatar } from "../component"
6
+ import {
7
+ DEFAULT_BEZEL_ANGLE,
8
+ removeUserAvatarInstance,
9
+ resetUserAvatarAtom,
10
+ setUserAvatarBezelAngleAtom,
11
+ setUserAvatarPresenceAtom,
12
+ toggleUserAvatarPresenceAtom,
13
+ userAvatarBezelAngleAtom,
14
+ userAvatarIsOnlineAtom,
15
+ userAvatarPresenceAtom,
16
+ userAvatarStateAtom,
17
+ } from "../user-avatar-atom"
18
+ import { useUserAvatarActions, useUserAvatarState } from "../use-user-avatar-state"
19
+ import type { UserAvatarActions, UserAvatarState } from "../type"
20
+
21
+ const badge = () => document.querySelector('[data-slot="user-avatar-presence"]')
22
+ const bezel = () => document.querySelector('[data-slot="user-avatar-bezel"]')
23
+ const frame = () =>
24
+ document.querySelector<HTMLElement>('[data-slot="user-avatar-frame"]')
25
+ /** The rim's rotation, parsed. The component damps the stored bearing
26
+ * (`bezelAngle * 0.8`) so the highlight trails the pointer slightly. */
27
+ const BEZEL_DAMPING = 0.8
28
+ const rimRotation = () => {
29
+ const match = /rotate\((-?[\d.]+)deg\)/.exec(bezel()?.getAttribute("style") ?? "")
30
+ return match ? Number(match[1]) : Number.NaN
31
+ }
32
+
33
+ describe("UserAvatar · presence badge", () => {
34
+ test("no presence, no badge", () => {
35
+ render(<UserAvatar name="Jenny Hamilton" />)
36
+ expect(badge()).toBeNull()
37
+ })
38
+
39
+ test("online is a filled dot named for AT; offline a hollow ring", () => {
40
+ const { rerender } = render(
41
+ <UserAvatar name="Jenny Hamilton" presence="online" />
42
+ )
43
+ expect(badge()?.getAttribute("data-presence")).toBe("online")
44
+ expect(screen.getByRole("img", { name: "Online" })).toBeDefined()
45
+ expect(badge()?.className).toContain("bg-emerald-500")
46
+
47
+ rerender(<UserAvatar name="Jenny Hamilton" presence="offline" />)
48
+ expect(screen.getByRole("img", { name: "Offline" })).toBeDefined()
49
+ expect(badge()?.className).toContain("border-2")
50
+
51
+ // Releasing the prop clears the badge — a real transition for a user.
52
+ rerender(<UserAvatar name="Jenny Hamilton" />)
53
+ expect(badge()).toBeNull()
54
+ })
55
+
56
+ test("online reflects the badge colour onto the disc; offline does not", async () => {
57
+ const reflection = () =>
58
+ document.querySelector('[data-slot="user-avatar-reflection"]')
59
+ const { rerender } = render(
60
+ <UserAvatar name="Jenny Hamilton" presence="online" />
61
+ )
62
+ expect(reflection()).not.toBeNull()
63
+ // Static — no transform, unlike the bezel rim.
64
+ expect(reflection()?.getAttribute("style")).toBeNull()
65
+ expect(reflection()?.className).toContain("radial-gradient")
66
+ // Initials disc: the smaller, fainter pool.
67
+ expect(reflection()?.hasAttribute("data-image")).toBe(false)
68
+ expect(reflection()?.className).toContain("[--reflect-peak:16%]")
69
+ // A requested-but-not-loaded src still shows initials — still the small
70
+ // pool; only a loaded photo gets the larger one.
71
+ // happy-dom's Image reports naturalWidth 0 (Base UI reads that as a
72
+ // failed preload) — a broken src keeps the initials and the small pool.
73
+ rerender(
74
+ <UserAvatar name="Jenny Hamilton" presence="online" src="/broken.jpg" />
75
+ )
76
+ expect(reflection()?.hasAttribute("data-image")).toBe(false)
77
+
78
+ // Stand in a preload that succeeds: now a photo is on screen.
79
+ const RealImage = window.Image
80
+ window.Image = class {
81
+ complete = true
82
+ naturalWidth = 72
83
+ onload: (() => void) | null = null
84
+ onerror: (() => void) | null = null
85
+ src = ""
86
+ } as unknown as typeof window.Image
87
+ try {
88
+ rerender(
89
+ <UserAvatar name="Jenny Hamilton" presence="online" src="/ok.jpg" />
90
+ )
91
+ await act(async () => {
92
+ await new Promise((r) => setTimeout(r, 0))
93
+ })
94
+ expect(reflection()?.hasAttribute("data-image")).toBe(true)
95
+ expect(reflection()?.className).toContain("[--reflect-peak:30%]")
96
+ } finally {
97
+ window.Image = RealImage
98
+ }
99
+ rerender(<UserAvatar name="Jenny Hamilton" presence="offline" />)
100
+ expect(reflection()).toBeNull()
101
+ })
102
+
103
+ test("the badge carries a sheen lit from the bezel angle; none without a bezel", async () => {
104
+ const sheen = () =>
105
+ document.querySelector('[data-slot="user-avatar-presence-sheen"]')
106
+ const { rerender } = render(
107
+ <UserAvatar avatarId="sheen" name="Jenny Hamilton" presence="online" />
108
+ )
109
+ expect(sheen()).not.toBeNull()
110
+ const rot = () =>
111
+ Number(/rotate\((-?[\d.]+)deg\)/.exec(sheen()?.getAttribute("style") ?? "")?.[1])
112
+ expect(rot()).toBeCloseTo(DEFAULT_BEZEL_ANGLE * BEZEL_DAMPING, 5)
113
+
114
+ // Same light as the rim: a pointer move rotates both together.
115
+ const host = frame() as HTMLElement
116
+ host.getBoundingClientRect = () =>
117
+ ({ left: 100, top: 100, width: 40, height: 40, right: 140, bottom: 140 }) as DOMRect
118
+ await act(async () => {
119
+ fireEvent.pointerMove(window, { clientX: 200, clientY: 120 })
120
+ await new Promise((r) => requestAnimationFrame(() => r(null)))
121
+ })
122
+ expect(rot()).toBeCloseTo(90 * BEZEL_DAMPING, 5)
123
+ expect(rot()).toBeCloseTo(rimRotation(), 5)
124
+
125
+ rerender(<UserAvatar bezel={false} name="Jenny Hamilton" presence="online" />)
126
+ expect(sheen()).toBeNull()
127
+ removeUserAvatarInstance("sheen")
128
+ })
129
+
130
+ test("the root carries data-presence for styling hooks", () => {
131
+ render(<UserAvatar name="Jenny Hamilton" presence="online" />)
132
+ expect(
133
+ document
134
+ .querySelector('[data-slot="user-avatar"]')
135
+ ?.getAttribute("data-presence")
136
+ ).toBe("online")
137
+ })
138
+ })
139
+
140
+ describe("UserAvatar · bezel", () => {
141
+ test("on by default: an emboss shadow on the root and a rotated rim", () => {
142
+ render(<UserAvatar name="Jenny Hamilton" />)
143
+ const root = document.querySelector('[data-slot="user-avatar"]')
144
+ expect(root?.hasAttribute("data-bezel")).toBe(true)
145
+ expect(root?.className).toContain("shadow-[inset")
146
+ // Rests at the classic top-left light until a pointer moves.
147
+ expect(rimRotation()).toBeCloseTo(DEFAULT_BEZEL_ANGLE * BEZEL_DAMPING, 5)
148
+ })
149
+
150
+ test("bezel={false} renders a flat disc", () => {
151
+ render(<UserAvatar bezel={false} name="Jenny Hamilton" />)
152
+ expect(bezel()).toBeNull()
153
+ expect(
154
+ document.querySelector('[data-slot="user-avatar"]')?.hasAttribute("data-bezel")
155
+ ).toBe(false)
156
+ })
157
+
158
+ test("the rim follows the pointer's bearing from the avatar's centre", async () => {
159
+ render(<UserAvatar avatarId="lit" name="Jenny Hamilton" />)
160
+ const host = frame() as HTMLElement
161
+ // happy-dom rects are 0×0; give the frame a box the hook can measure.
162
+ host.getBoundingClientRect = () =>
163
+ ({ left: 100, top: 100, width: 40, height: 40, right: 140, bottom: 140 }) as DOMRect
164
+
165
+ // Pointer straight to the right of centre (120,120) → 3 o'clock → 90°.
166
+ await act(async () => {
167
+ fireEvent.pointerMove(window, { clientX: 200, clientY: 120 })
168
+ await new Promise((r) => requestAnimationFrame(() => r(null)))
169
+ })
170
+ expect(rimRotation()).toBeCloseTo(90 * BEZEL_DAMPING, 5)
171
+
172
+ // Straight below → 6 o'clock → 180°.
173
+ await act(async () => {
174
+ fireEvent.pointerMove(window, { clientX: 120, clientY: 300 })
175
+ await new Promise((r) => requestAnimationFrame(() => r(null)))
176
+ })
177
+ expect(rimRotation()).toBeCloseTo(180 * BEZEL_DAMPING, 5)
178
+ removeUserAvatarInstance("lit")
179
+ })
180
+ })
181
+
182
+ describe("UserAvatar · store", () => {
183
+ test("presence, toggle, isOnline and reset on an isolated store", () => {
184
+ const store = createStore()
185
+ expect(store.get(userAvatarPresenceAtom("a"))).toBeNull()
186
+ store.set(setUserAvatarPresenceAtom("a"), "online")
187
+ expect(store.get(userAvatarIsOnlineAtom("a"))).toBe(true)
188
+ store.set(toggleUserAvatarPresenceAtom("a"))
189
+ expect(store.get(userAvatarPresenceAtom("a"))).toBe("offline")
190
+ // A badge-less avatar comes online on toggle.
191
+ store.set(setUserAvatarPresenceAtom("a"), null)
192
+ store.set(toggleUserAvatarPresenceAtom("a"))
193
+ expect(store.get(userAvatarPresenceAtom("a"))).toBe("online")
194
+
195
+ store.set(setUserAvatarBezelAngleAtom("a"), 400)
196
+ // Continuous — not wrapped — so a CSS rotation takes the short way.
197
+ expect(store.get(userAvatarBezelAngleAtom("a"))).toBe(400)
198
+ store.set(setUserAvatarBezelAngleAtom("a"), Number.NaN)
199
+ expect(store.get(userAvatarBezelAngleAtom("a"))).toBe(400)
200
+
201
+ store.set(resetUserAvatarAtom("a"))
202
+ expect(store.get(userAvatarStateAtom("a"))).toEqual({
203
+ presence: null,
204
+ bezelAngle: DEFAULT_BEZEL_ANGLE,
205
+ imageStatus: "idle",
206
+ })
207
+ removeUserAvatarInstance("a")
208
+ })
209
+
210
+ test("a controlled presence prop wins over store writes; hooks by id read and drive", () => {
211
+ let actions!: UserAvatarActions
212
+ let state!: UserAvatarState
213
+ function Probe() {
214
+ actions = useUserAvatarActions("me")
215
+ state = useUserAvatarState("me")
216
+ return null
217
+ }
218
+ const store = createStore()
219
+ const { rerender } = render(
220
+ <Provider store={store}>
221
+ <UserAvatar avatarId="me" name="Jenny Hamilton" presence="offline" />
222
+ <Probe />
223
+ </Provider>
224
+ )
225
+ expect(state.presence).toBe("offline")
226
+ act(() => actions.setPresence("online"))
227
+ // Controlled: the store write is inert, the badge stays offline.
228
+ expect(badge()?.getAttribute("data-presence")).toBe("offline")
229
+
230
+ // Uncontrolled: the store drives the badge from anywhere.
231
+ rerender(
232
+ <Provider store={store}>
233
+ <UserAvatar avatarId="me" name="Jenny Hamilton" />
234
+ <Probe />
235
+ </Provider>
236
+ )
237
+ expect(badge()).toBeNull()
238
+ act(() => actions.setPresence("online"))
239
+ expect(badge()?.getAttribute("data-presence")).toBe("online")
240
+ expect(state.presence).toBe("online")
241
+ act(() => actions.togglePresence())
242
+ expect(badge()?.getAttribute("data-presence")).toBe("offline")
243
+ removeUserAvatarInstance("me")
244
+ })
245
+ })
@@ -5,49 +5,148 @@ import * as React from "react"
5
5
  import { Avatar, AvatarImage } from "@/components/ui/avatar"
6
6
  import { cn } from "@/lib/utils"
7
7
  import { Fallback } from "./fallback"
8
- import type { ImageStatus, UserAvatarProps } from "./type"
8
+ import { PresenceBadge } from "./presence-badge"
9
+ import type { UserAvatarProps } from "./type"
10
+ import { useUserAvatar } from "./use-user-avatar"
9
11
 
10
12
  /**
11
- * Identity avatar: an image when one is given, initials otherwise.
13
+ * Identity avatar: an image when one is given, initials otherwise, with an
14
+ * optional presence badge and a pointer-lit bezel.
12
15
  *
13
16
  * A passed `src` is assumed to be remote, so the slot holds a skeleton until
14
17
  * the image resolves rather than flashing initials that are about to be
15
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`.
16
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
+
17
28
  export function UserAvatar({
18
29
  src,
19
30
  name = "",
20
31
  initials,
21
32
  alt,
22
33
  loading: loadingProp,
34
+ presence: presenceProp,
35
+ bezel = true,
36
+ avatarId,
23
37
  className,
38
+ style,
24
39
  ...props
25
40
  }: UserAvatarProps): React.ReactElement {
26
- const [status, setStatus] = React.useState<ImageStatus>("idle")
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
+
27
50
  const loading =
28
51
  loadingProp ??
29
- (src !== undefined && (status === "idle" || status === "loading"))
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"
30
57
 
31
58
  return (
32
- <Avatar
33
- className={cn(
34
- "size-8 border-2 border-white bg-neutral-800 text-xl",
35
- className
36
- )}
37
- data-loading={loading || undefined}
38
- data-slot="user-avatar"
39
- {...props}
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>}
40
66
  >
41
- {/* Held back while forced-loading: a loaded image would hide the
42
- fallback the skeleton lives in. */}
43
- {src === undefined || loadingProp === true ? null : (
44
- <AvatarImage
45
- alt={alt ?? name}
46
- onLoadingStatusChange={setStatus}
47
- src={src}
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}
48
148
  />
49
- )}
50
- <Fallback name={name} initials={initials} loading={loading} />
51
- </Avatar>
149
+ ) : null}
150
+ </span>
52
151
  )
53
152
  }
@@ -5,7 +5,26 @@ import { Fallback } from "./fallback"
5
5
  export { AudioWave } from "./audio-wave"
6
6
  export { UserAvatar } from "./component"
7
7
  export { Fallback } from "./fallback"
8
+ export { PresenceBadge } from "./presence-badge"
8
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"
9
28
  export type * from "./type"
10
29
 
11
30
  export const Avatar = {
@@ -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
+ }
@@ -6,6 +6,13 @@ export type ImageStatus = Parameters<
6
6
  NonNullable<AvatarPrimitive.Image.Props["onLoadingStatusChange"]>
7
7
  >[0]
8
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
+
9
16
  export interface UserAvatarProps extends Omit<
10
17
  AvatarPrimitive.Root.Props,
11
18
  "children"
@@ -26,6 +33,49 @@ export interface UserAvatarProps extends Omit<
26
33
  * Omitted, it follows the image: a passed `src` skeletons until it resolves.
27
34
  */
28
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
29
79
  }
30
80
 
31
81
  export interface IAudioWaveProps {
@@ -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
+ }