@exegia/corpora-ui 0.20.0 → 0.22.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.
- package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
- package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
- package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
- package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
- package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
- package/dist-lib/components/blocks/layout.d.ts +4 -1
- package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
- package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
- package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/type.d.ts +109 -29
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +6 -1
- package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +1 -1
- package/dist-lib/components/blocks/profile/index.d.ts +7 -0
- package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
- package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
- package/dist-lib/components/blocks/profile/type.d.ts +80 -0
- package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
- package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
- package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
- package/dist-lib/components/blocks/shell/index.d.ts +4 -12
- package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
- package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
- package/dist-lib/components/blocks/shell/type.d.ts +115 -3
- package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
- package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
- package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
- package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
- package/dist-lib/components/composed/__tests__/logo.test.d.ts +1 -0
- package/dist-lib/components/composed/logo.d.ts +34 -0
- package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/constants.d.ts +4 -4
- package/dist-lib/components/composed/tree/index.d.ts +3 -1
- package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
- package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
- package/dist-lib/components/composed/tree/type.d.ts +116 -18
- package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
- package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
- package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
- package/dist-lib/components/composed/user-avatar.d.ts +5 -28
- package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
- package/dist-lib/components/user-avatar/component.d.ts +1 -8
- package/dist-lib/components/user-avatar/index.d.ts +5 -0
- package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
- package/dist-lib/components/user-avatar/type.d.ts +53 -0
- package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
- package/dist-lib/components/user-avatar/use-user-avatar.d.ts +37 -0
- package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +64 -0
- package/dist-lib/components/user-avatar/utils.d.ts +22 -0
- package/dist-lib/index.d.ts +6 -3
- package/dist-lib/index.js +3732 -2670
- package/dist-lib/index.js.map +1 -1
- package/dist-lib/state/exegia-provider.d.ts +51 -0
- package/dist-lib/state/index.d.ts +4 -0
- package/dist-lib/state/store.d.ts +17 -0
- package/package.json +15 -12
- package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
- package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
- package/src/components/blocks/auth/auth-session-atom.ts +82 -0
- package/src/components/blocks/auth/auth-state-type.ts +97 -0
- package/src/components/blocks/auth/auth-state.ts +52 -0
- package/src/components/blocks/auth/use-auth-state.ts +127 -0
- package/src/components/blocks/layout.ts +30 -2
- package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
- package/src/components/blocks/nav/sidebar/index.ts +53 -1
- package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
- package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
- package/src/components/blocks/nav/sidebar/type.ts +125 -30
- package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
- package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
- package/src/components/blocks/nav/sidebar/utils.ts +1 -1
- package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
- package/src/components/blocks/profile/index.ts +28 -0
- package/src/components/blocks/profile/profile-card-atom.ts +247 -0
- package/src/components/blocks/profile/profile-card-block.tsx +153 -74
- package/src/components/blocks/profile/type.ts +95 -0
- package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
- package/src/components/blocks/profile/use-profile-card.ts +126 -0
- package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
- package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
- package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
- package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
- package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
- package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
- package/src/components/blocks/shell/animated-panel.tsx +126 -29
- package/src/components/blocks/shell/index.ts +18 -13
- package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
- package/src/components/blocks/shell/shell-layout.tsx +55 -53
- package/src/components/blocks/shell/shell-metrics.ts +79 -0
- package/src/components/blocks/shell/type.ts +130 -3
- package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
- package/src/components/blocks/shell/use-shell-fit.ts +135 -0
- package/src/components/blocks/shell/use-shell-panels.ts +57 -4
- package/src/components/blocks/shell/utils.ts +44 -4
- package/src/components/composed/__tests__/logo.test.tsx +60 -0
- package/src/components/composed/logo.tsx +159 -0
- package/src/components/composed/tree/CLAUDE.md +132 -0
- package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
- package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
- package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
- package/src/components/composed/tree/constants.ts +4 -4
- package/src/components/composed/tree/index.ts +38 -0
- package/src/components/composed/tree/tree-atom.ts +590 -0
- package/src/components/composed/tree/tree-node.tsx +117 -78
- package/src/components/composed/tree/tree.tsx +24 -16
- package/src/components/composed/tree/type.ts +125 -15
- package/src/components/composed/tree/use-tree-dnd.ts +82 -51
- package/src/components/composed/tree/use-tree-state.ts +105 -0
- package/src/components/composed/tree/use-tree.ts +185 -184
- package/src/components/composed/user-avatar.tsx +11 -99
- package/src/components/user-avatar/__tests__/user-avatar.test.tsx +286 -0
- package/src/components/user-avatar/component.tsx +151 -22
- package/src/components/user-avatar/fallback.tsx +3 -1
- package/src/components/user-avatar/index.ts +19 -0
- package/src/components/user-avatar/presence-badge.tsx +56 -0
- package/src/components/user-avatar/type.ts +58 -0
- package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
- package/src/components/user-avatar/use-user-avatar.ts +180 -0
- package/src/components/user-avatar/user-avatar-atom.ts +218 -0
- package/src/components/user-avatar/utils.ts +94 -0
- package/src/index.ts +35 -3
- package/src/state/exegia-provider.tsx +79 -0
- package/src/state/index.ts +4 -0
- package/src/state/store.ts +19 -0
- package/dist-lib/components/blocks/nav/sidebar-block.d.ts +0 -14
- package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +0 -3
- package/dist-lib/components/blocks/nav/types.d.ts +0 -60
- package/dist-lib/components/blocks/nav/utils.d.ts +0 -1
- package/dist-lib/components/blocks/shell/animated-sidebar-content.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-footer.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group-content.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group-label.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-header.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-button.d.ts +0 -3
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-item.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-button.d.ts +0 -3
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-item.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu.d.ts +0 -2
- package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +0 -125
- package/src/components/blocks/nav/sidebar-block.tsx +0 -142
- package/src/components/blocks/nav/sidebar-nav-row.tsx +0 -78
- package/src/components/blocks/nav/types.ts +0 -65
- package/src/components/blocks/nav/utils.ts +0 -4
- package/src/components/blocks/shell/animated-sidebar-content.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-footer.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-group-content.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-group-label.tsx +0 -29
- package/src/components/blocks/shell/animated-sidebar-group.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-header.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-menu-button.tsx +0 -151
- package/src/components/blocks/shell/animated-sidebar-menu-item.tsx +0 -20
- package/src/components/blocks/shell/animated-sidebar-menu-sub-button.tsx +0 -85
- package/src/components/blocks/shell/animated-sidebar-menu-sub-item.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-menu-sub.tsx +0 -44
- package/src/components/blocks/shell/animated-sidebar-menu.tsx +0 -26
- /package/dist-lib/components/blocks/{nav/__tests__/sidebar-block.test.d.ts → auth/__tests__/auth-state-atom.test.d.ts} +0 -0
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
setUserAvatarImageToneAtom,
|
|
12
|
+
setUserAvatarPresenceAtom,
|
|
13
|
+
toggleUserAvatarPresenceAtom,
|
|
14
|
+
userAvatarBezelAngleAtom,
|
|
15
|
+
userAvatarImageToneAtom,
|
|
16
|
+
userAvatarIsOnlineAtom,
|
|
17
|
+
userAvatarPresenceAtom,
|
|
18
|
+
userAvatarStateAtom,
|
|
19
|
+
} from "../user-avatar-atom"
|
|
20
|
+
import { bezelAlphasForTone } from "../utils"
|
|
21
|
+
import { useUserAvatarActions, useUserAvatarState } from "../use-user-avatar-state"
|
|
22
|
+
import type { UserAvatarActions, UserAvatarState } from "../type"
|
|
23
|
+
|
|
24
|
+
const badge = () => document.querySelector('[data-slot="user-avatar-presence"]')
|
|
25
|
+
const bezel = () => document.querySelector('[data-slot="user-avatar-bezel"]')
|
|
26
|
+
const frame = () =>
|
|
27
|
+
document.querySelector<HTMLElement>('[data-slot="user-avatar-frame"]')
|
|
28
|
+
/** The rim's rotation, parsed. The component damps the stored bearing
|
|
29
|
+
* (`bezelAngle * 0.8`) so the highlight trails the pointer slightly. */
|
|
30
|
+
const BEZEL_DAMPING = 0.8
|
|
31
|
+
const rimRotation = () => {
|
|
32
|
+
const match = /rotate\((-?[\d.]+)deg\)/.exec(bezel()?.getAttribute("style") ?? "")
|
|
33
|
+
return match ? Number(match[1]) : Number.NaN
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("UserAvatar · presence badge", () => {
|
|
37
|
+
test("no presence, no badge", () => {
|
|
38
|
+
render(<UserAvatar name="Jenny Hamilton" />)
|
|
39
|
+
expect(badge()).toBeNull()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("online is a filled dot named for AT; offline a hollow ring", () => {
|
|
43
|
+
const { rerender } = render(
|
|
44
|
+
<UserAvatar name="Jenny Hamilton" presence="online" />
|
|
45
|
+
)
|
|
46
|
+
expect(badge()?.getAttribute("data-presence")).toBe("online")
|
|
47
|
+
expect(screen.getByRole("img", { name: "Online" })).toBeDefined()
|
|
48
|
+
expect(badge()?.className).toContain("bg-emerald-500")
|
|
49
|
+
|
|
50
|
+
rerender(<UserAvatar name="Jenny Hamilton" presence="offline" />)
|
|
51
|
+
expect(screen.getByRole("img", { name: "Offline" })).toBeDefined()
|
|
52
|
+
expect(badge()?.className).toContain("border-2")
|
|
53
|
+
|
|
54
|
+
// Releasing the prop clears the badge — a real transition for a user.
|
|
55
|
+
rerender(<UserAvatar name="Jenny Hamilton" />)
|
|
56
|
+
expect(badge()).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("online reflects the badge colour onto the disc; offline does not", async () => {
|
|
60
|
+
const reflection = () =>
|
|
61
|
+
document.querySelector('[data-slot="user-avatar-reflection"]')
|
|
62
|
+
const { rerender } = render(
|
|
63
|
+
<UserAvatar name="Jenny Hamilton" presence="online" />
|
|
64
|
+
)
|
|
65
|
+
expect(reflection()).not.toBeNull()
|
|
66
|
+
// Static — no transform, unlike the bezel rim.
|
|
67
|
+
expect(reflection()?.getAttribute("style")).toBeNull()
|
|
68
|
+
expect(reflection()?.className).toContain("radial-gradient")
|
|
69
|
+
// Initials disc: the smaller, fainter pool.
|
|
70
|
+
expect(reflection()?.hasAttribute("data-image")).toBe(false)
|
|
71
|
+
expect(reflection()?.className).toContain("[--reflect-peak:16%]")
|
|
72
|
+
// A requested-but-not-loaded src still shows initials — still the small
|
|
73
|
+
// pool; only a loaded photo gets the larger one.
|
|
74
|
+
// happy-dom's Image reports naturalWidth 0 (Base UI reads that as a
|
|
75
|
+
// failed preload) — a broken src keeps the initials and the small pool.
|
|
76
|
+
rerender(
|
|
77
|
+
<UserAvatar name="Jenny Hamilton" presence="online" src="/broken.jpg" />
|
|
78
|
+
)
|
|
79
|
+
expect(reflection()?.hasAttribute("data-image")).toBe(false)
|
|
80
|
+
|
|
81
|
+
// Stand in a preload that succeeds: now a photo is on screen.
|
|
82
|
+
const RealImage = window.Image
|
|
83
|
+
window.Image = class {
|
|
84
|
+
complete = true
|
|
85
|
+
naturalWidth = 72
|
|
86
|
+
onload: (() => void) | null = null
|
|
87
|
+
onerror: (() => void) | null = null
|
|
88
|
+
src = ""
|
|
89
|
+
} as unknown as typeof window.Image
|
|
90
|
+
try {
|
|
91
|
+
rerender(
|
|
92
|
+
<UserAvatar name="Jenny Hamilton" presence="online" src="/ok.jpg" />
|
|
93
|
+
)
|
|
94
|
+
await act(async () => {
|
|
95
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
96
|
+
})
|
|
97
|
+
expect(reflection()?.hasAttribute("data-image")).toBe(true)
|
|
98
|
+
expect(reflection()?.className).toContain("[--reflect-peak:30%]")
|
|
99
|
+
} finally {
|
|
100
|
+
window.Image = RealImage
|
|
101
|
+
}
|
|
102
|
+
rerender(<UserAvatar name="Jenny Hamilton" presence="offline" />)
|
|
103
|
+
expect(reflection()).toBeNull()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("the badge carries a sheen lit from the bezel angle; none without a bezel", async () => {
|
|
107
|
+
const sheen = () =>
|
|
108
|
+
document.querySelector('[data-slot="user-avatar-presence-sheen"]')
|
|
109
|
+
const { rerender } = render(
|
|
110
|
+
<UserAvatar avatarId="sheen" name="Jenny Hamilton" presence="online" />
|
|
111
|
+
)
|
|
112
|
+
expect(sheen()).not.toBeNull()
|
|
113
|
+
const rot = () =>
|
|
114
|
+
Number(/rotate\((-?[\d.]+)deg\)/.exec(sheen()?.getAttribute("style") ?? "")?.[1])
|
|
115
|
+
expect(rot()).toBeCloseTo(DEFAULT_BEZEL_ANGLE * BEZEL_DAMPING, 5)
|
|
116
|
+
|
|
117
|
+
// Same light as the rim: a pointer move rotates both together.
|
|
118
|
+
const host = frame() as HTMLElement
|
|
119
|
+
host.getBoundingClientRect = () =>
|
|
120
|
+
({ left: 100, top: 100, width: 40, height: 40, right: 140, bottom: 140 }) as DOMRect
|
|
121
|
+
await act(async () => {
|
|
122
|
+
fireEvent.pointerMove(window, { clientX: 200, clientY: 120 })
|
|
123
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
|
124
|
+
})
|
|
125
|
+
expect(rot()).toBeCloseTo(90 * BEZEL_DAMPING, 5)
|
|
126
|
+
expect(rot()).toBeCloseTo(rimRotation(), 5)
|
|
127
|
+
|
|
128
|
+
rerender(<UserAvatar bezel={false} name="Jenny Hamilton" presence="online" />)
|
|
129
|
+
expect(sheen()).toBeNull()
|
|
130
|
+
removeUserAvatarInstance("sheen")
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test("the root carries data-presence for styling hooks", () => {
|
|
134
|
+
render(<UserAvatar name="Jenny Hamilton" presence="online" />)
|
|
135
|
+
expect(
|
|
136
|
+
document
|
|
137
|
+
.querySelector('[data-slot="user-avatar"]')
|
|
138
|
+
?.getAttribute("data-presence")
|
|
139
|
+
).toBe("online")
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe("UserAvatar · bezel", () => {
|
|
144
|
+
test("on by default: an emboss shadow on the root and a rotated rim", () => {
|
|
145
|
+
render(<UserAvatar name="Jenny Hamilton" />)
|
|
146
|
+
const root = document.querySelector('[data-slot="user-avatar"]')
|
|
147
|
+
expect(root?.hasAttribute("data-bezel")).toBe(true)
|
|
148
|
+
expect(root?.className).toContain("shadow-[inset")
|
|
149
|
+
// Rests at the classic top-left light until a pointer moves.
|
|
150
|
+
expect(rimRotation()).toBeCloseTo(DEFAULT_BEZEL_ANGLE * BEZEL_DAMPING, 5)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test("bezel={false} renders a flat disc", () => {
|
|
154
|
+
render(<UserAvatar bezel={false} name="Jenny Hamilton" />)
|
|
155
|
+
expect(bezel()).toBeNull()
|
|
156
|
+
expect(
|
|
157
|
+
document.querySelector('[data-slot="user-avatar"]')?.hasAttribute("data-bezel")
|
|
158
|
+
).toBe(false)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("the rim follows the pointer's bearing from the avatar's centre", async () => {
|
|
162
|
+
render(<UserAvatar avatarId="lit" name="Jenny Hamilton" />)
|
|
163
|
+
const host = frame() as HTMLElement
|
|
164
|
+
// happy-dom rects are 0×0; give the frame a box the hook can measure.
|
|
165
|
+
host.getBoundingClientRect = () =>
|
|
166
|
+
({ left: 100, top: 100, width: 40, height: 40, right: 140, bottom: 140 }) as DOMRect
|
|
167
|
+
|
|
168
|
+
// Pointer straight to the right of centre (120,120) → 3 o'clock → 90°.
|
|
169
|
+
await act(async () => {
|
|
170
|
+
fireEvent.pointerMove(window, { clientX: 200, clientY: 120 })
|
|
171
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
|
172
|
+
})
|
|
173
|
+
expect(rimRotation()).toBeCloseTo(90 * BEZEL_DAMPING, 5)
|
|
174
|
+
|
|
175
|
+
// Straight below → 6 o'clock → 180°.
|
|
176
|
+
await act(async () => {
|
|
177
|
+
fireEvent.pointerMove(window, { clientX: 120, clientY: 300 })
|
|
178
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
|
179
|
+
})
|
|
180
|
+
expect(rimRotation()).toBeCloseTo(180 * BEZEL_DAMPING, 5)
|
|
181
|
+
removeUserAvatarInstance("lit")
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe("UserAvatar · bezel tone", () => {
|
|
186
|
+
test("alphas lean on the shadow over a pale rim and ease the highlight over a dark one", () => {
|
|
187
|
+
const dark = bezelAlphasForTone(0)
|
|
188
|
+
const light = bezelAlphasForTone(1)
|
|
189
|
+
expect(dark.hi).toBeLessThan(light.hi)
|
|
190
|
+
expect(dark.lo).toBeGreaterThan(light.lo)
|
|
191
|
+
// Out-of-range input clamps rather than extrapolating past [0, 1].
|
|
192
|
+
expect(bezelAlphasForTone(-3)).toEqual(dark)
|
|
193
|
+
expect(bezelAlphasForTone(7)).toEqual(light)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test("without a sampled tone the rim carries no inline alphas — the theme's defaults apply", () => {
|
|
197
|
+
render(<UserAvatar name="Jenny Hamilton" />)
|
|
198
|
+
const rim = bezel()
|
|
199
|
+
expect(rim?.hasAttribute("data-tone")).toBe(false)
|
|
200
|
+
expect(rim?.getAttribute("style")).not.toContain("--bezel-hi-a")
|
|
201
|
+
// The defaults live in classes, per theme, so a photo-less disc is lit
|
|
202
|
+
// for the page it sits on.
|
|
203
|
+
expect(rim?.className).toContain("[--bezel-hi-a:1]")
|
|
204
|
+
expect(rim?.className).toContain("dark:[--bezel-hi-a:0.18]")
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test("the tone atom clamps and clears", () => {
|
|
208
|
+
const store = createStore()
|
|
209
|
+
store.set(setUserAvatarImageToneAtom("t"), 1.7)
|
|
210
|
+
expect(store.get(userAvatarImageToneAtom("t"))).toBe(1)
|
|
211
|
+
store.set(setUserAvatarImageToneAtom("t"), 0.3)
|
|
212
|
+
expect(store.get(userAvatarImageToneAtom("t"))).toBe(0.3)
|
|
213
|
+
store.set(setUserAvatarImageToneAtom("t"), Number.NaN)
|
|
214
|
+
expect(store.get(userAvatarImageToneAtom("t"))).toBeNull()
|
|
215
|
+
store.set(setUserAvatarImageToneAtom("t"), 0.5)
|
|
216
|
+
store.set(resetUserAvatarAtom("t"))
|
|
217
|
+
expect(store.get(userAvatarImageToneAtom("t"))).toBeNull()
|
|
218
|
+
removeUserAvatarInstance("t")
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe("UserAvatar · store", () => {
|
|
223
|
+
test("presence, toggle, isOnline and reset on an isolated store", () => {
|
|
224
|
+
const store = createStore()
|
|
225
|
+
expect(store.get(userAvatarPresenceAtom("a"))).toBeNull()
|
|
226
|
+
store.set(setUserAvatarPresenceAtom("a"), "online")
|
|
227
|
+
expect(store.get(userAvatarIsOnlineAtom("a"))).toBe(true)
|
|
228
|
+
store.set(toggleUserAvatarPresenceAtom("a"))
|
|
229
|
+
expect(store.get(userAvatarPresenceAtom("a"))).toBe("offline")
|
|
230
|
+
// A badge-less avatar comes online on toggle.
|
|
231
|
+
store.set(setUserAvatarPresenceAtom("a"), null)
|
|
232
|
+
store.set(toggleUserAvatarPresenceAtom("a"))
|
|
233
|
+
expect(store.get(userAvatarPresenceAtom("a"))).toBe("online")
|
|
234
|
+
|
|
235
|
+
store.set(setUserAvatarBezelAngleAtom("a"), 400)
|
|
236
|
+
// Continuous — not wrapped — so a CSS rotation takes the short way.
|
|
237
|
+
expect(store.get(userAvatarBezelAngleAtom("a"))).toBe(400)
|
|
238
|
+
store.set(setUserAvatarBezelAngleAtom("a"), Number.NaN)
|
|
239
|
+
expect(store.get(userAvatarBezelAngleAtom("a"))).toBe(400)
|
|
240
|
+
|
|
241
|
+
store.set(resetUserAvatarAtom("a"))
|
|
242
|
+
expect(store.get(userAvatarStateAtom("a"))).toEqual({
|
|
243
|
+
presence: null,
|
|
244
|
+
bezelAngle: DEFAULT_BEZEL_ANGLE,
|
|
245
|
+
imageStatus: "idle",
|
|
246
|
+
imageTone: null,
|
|
247
|
+
})
|
|
248
|
+
removeUserAvatarInstance("a")
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test("a controlled presence prop wins over store writes; hooks by id read and drive", () => {
|
|
252
|
+
let actions!: UserAvatarActions
|
|
253
|
+
let state!: UserAvatarState
|
|
254
|
+
function Probe() {
|
|
255
|
+
actions = useUserAvatarActions("me")
|
|
256
|
+
state = useUserAvatarState("me")
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
const store = createStore()
|
|
260
|
+
const { rerender } = render(
|
|
261
|
+
<Provider store={store}>
|
|
262
|
+
<UserAvatar avatarId="me" name="Jenny Hamilton" presence="offline" />
|
|
263
|
+
<Probe />
|
|
264
|
+
</Provider>
|
|
265
|
+
)
|
|
266
|
+
expect(state.presence).toBe("offline")
|
|
267
|
+
act(() => actions.setPresence("online"))
|
|
268
|
+
// Controlled: the store write is inert, the badge stays offline.
|
|
269
|
+
expect(badge()?.getAttribute("data-presence")).toBe("offline")
|
|
270
|
+
|
|
271
|
+
// Uncontrolled: the store drives the badge from anywhere.
|
|
272
|
+
rerender(
|
|
273
|
+
<Provider store={store}>
|
|
274
|
+
<UserAvatar avatarId="me" name="Jenny Hamilton" />
|
|
275
|
+
<Probe />
|
|
276
|
+
</Provider>
|
|
277
|
+
)
|
|
278
|
+
expect(badge()).toBeNull()
|
|
279
|
+
act(() => actions.setPresence("online"))
|
|
280
|
+
expect(badge()?.getAttribute("data-presence")).toBe("online")
|
|
281
|
+
expect(state.presence).toBe("online")
|
|
282
|
+
act(() => actions.togglePresence())
|
|
283
|
+
expect(badge()?.getAttribute("data-presence")).toBe("offline")
|
|
284
|
+
removeUserAvatarInstance("me")
|
|
285
|
+
})
|
|
286
|
+
})
|
|
@@ -5,49 +5,178 @@ 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
|
|
8
|
+
import { PresenceBadge } from "./presence-badge"
|
|
9
|
+
import type { UserAvatarProps } from "./type"
|
|
10
|
+
import { useUserAvatar } from "./use-user-avatar"
|
|
11
|
+
import { bezelAlphasForTone } from "./utils"
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
|
-
* Identity avatar: an image when one is given, initials otherwise
|
|
14
|
+
* Identity avatar: an image when one is given, initials otherwise, with an
|
|
15
|
+
* optional presence badge and a pointer-lit bezel.
|
|
12
16
|
*
|
|
13
17
|
* A passed `src` is assumed to be remote, so the slot holds a skeleton until
|
|
14
18
|
* the image resolves rather than flashing initials that are about to be
|
|
15
19
|
* replaced. A failed load settles on the initials.
|
|
20
|
+
*
|
|
21
|
+
* State (presence, bezel angle, image status) lives in Jotai atoms keyed by
|
|
22
|
+
* `avatarId` — name the avatar and `useUserAvatarActions(id).setPresence()`
|
|
23
|
+
* flips its badge from anywhere under `ExegiaProvider`.
|
|
16
24
|
*/
|
|
25
|
+
/** The rim (and the badge sheen) trail the pointer's bearing slightly rather
|
|
26
|
+
* than pointing dead at it — a touch of lag reads as weight. */
|
|
27
|
+
const BEZEL_DAMPING = 0.8
|
|
28
|
+
|
|
17
29
|
export function UserAvatar({
|
|
18
30
|
src,
|
|
19
31
|
name = "",
|
|
20
32
|
initials,
|
|
21
33
|
alt,
|
|
22
34
|
loading: loadingProp,
|
|
35
|
+
presence: presenceProp,
|
|
36
|
+
bezel = true,
|
|
37
|
+
avatarId,
|
|
23
38
|
className,
|
|
39
|
+
style,
|
|
24
40
|
...props
|
|
25
41
|
}: UserAvatarProps): React.ReactElement {
|
|
26
|
-
const
|
|
42
|
+
const hasImage = src !== undefined
|
|
43
|
+
const avatar = useUserAvatar({
|
|
44
|
+
avatarId,
|
|
45
|
+
presence: presenceProp,
|
|
46
|
+
bezel,
|
|
47
|
+
hasImage,
|
|
48
|
+
src,
|
|
49
|
+
})
|
|
50
|
+
const { presence, bezelAngle, imageStatus, imageTone, setImageStatus, ref } =
|
|
51
|
+
avatar
|
|
52
|
+
|
|
27
53
|
const loading =
|
|
28
54
|
loadingProp ??
|
|
29
|
-
(
|
|
55
|
+
(hasImage && (imageStatus === "idle" || imageStatus === "loading"))
|
|
56
|
+
// A photo is actually on screen — not merely requested. A broken or
|
|
57
|
+
// still-loading `src` shows initials/skeleton and should be treated as
|
|
58
|
+
// the flat disc it is.
|
|
59
|
+
const showsImage = hasImage && loadingProp !== true && imageStatus === "loaded"
|
|
60
|
+
// With a photo on screen and its rim sampled, the bezel's alphas follow the
|
|
61
|
+
// photo rather than the theme: the ring sits on the image, not the page.
|
|
62
|
+
const bezelAlphas =
|
|
63
|
+
showsImage && imageTone !== null ? bezelAlphasForTone(imageTone) : null
|
|
30
64
|
|
|
31
65
|
return (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
data-
|
|
38
|
-
|
|
39
|
-
{...props}
|
|
66
|
+
// The frame is what the badge and bezel hang off: the Avatar root clips
|
|
67
|
+
// to its circle, and the badge has to sit on the rim, half outside it.
|
|
68
|
+
// Sizing classes still target the Avatar — the frame shrink-wraps it.
|
|
69
|
+
<span
|
|
70
|
+
className="relative inline-flex shrink-0 rounded-full"
|
|
71
|
+
data-slot="user-avatar-frame"
|
|
72
|
+
ref={ref as React.RefObject<HTMLSpanElement>}
|
|
40
73
|
>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
74
|
+
<Avatar
|
|
75
|
+
className={cn(
|
|
76
|
+
// `@container` lets the fallback size its initials in cqw — a
|
|
77
|
+
// fraction of whatever `size-*` the caller put on this element —
|
|
78
|
+
// instead of a fixed text step. `text-xl` stays as the fallback for
|
|
79
|
+
// engines without container units.
|
|
80
|
+
"@container size-8 border-1 border-white bg-neutral-800! text-xl dark:border-neutral-900",
|
|
81
|
+
// The emboss itself: an inset drop toward the light's opposite side
|
|
82
|
+
// is what the ring below is rotated against.
|
|
83
|
+
bezel &&
|
|
84
|
+
"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%)]",
|
|
85
|
+
className
|
|
86
|
+
)}
|
|
87
|
+
data-avatar-id={avatar.avatarId}
|
|
88
|
+
data-bezel={bezel ? "" : undefined}
|
|
89
|
+
data-loading={loading || undefined}
|
|
90
|
+
data-presence={presence ?? undefined}
|
|
91
|
+
data-slot="user-avatar"
|
|
92
|
+
style={style}
|
|
93
|
+
{...props}
|
|
94
|
+
>
|
|
95
|
+
{/* Held back while forced-loading: a loaded image would hide the
|
|
96
|
+
fallback the skeleton lives in. */}
|
|
97
|
+
{!hasImage || loadingProp === true ? null : (
|
|
98
|
+
<AvatarImage
|
|
99
|
+
alt={alt ?? name}
|
|
100
|
+
onLoadingStatusChange={setImageStatus}
|
|
101
|
+
src={src}
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
<Fallback name={name} initials={initials} loading={loading} />
|
|
105
|
+
</Avatar>
|
|
106
|
+
{presence === "online" ? (
|
|
107
|
+
// The badge's green "reflected" onto the disc: a static radial wash
|
|
108
|
+
// anchored at the badge corner that fades inward, so the dot reads
|
|
109
|
+
// as a light source sitting on the avatar rather than a sticker on
|
|
110
|
+
// it. Does not move with the bezel — it belongs to the badge, not
|
|
111
|
+
// the pointer. Sits above the image, below the bezel and the badge.
|
|
112
|
+
<span
|
|
113
|
+
aria-hidden="true"
|
|
114
|
+
className={cn(
|
|
115
|
+
"pointer-events-none absolute inset-0 rounded-full",
|
|
116
|
+
// One gradient, four knobs: colour, peak alpha, mid alpha, reach.
|
|
117
|
+
// Light mode is quieter than dark (a green wash on a pale disc
|
|
118
|
+
// reads as a stain, on a dark one as a glow), and an initials
|
|
119
|
+
// disc — flat, no photo to sit on — takes a smaller, fainter
|
|
120
|
+
// pool than an image does.
|
|
121
|
+
"[--reflect:--theme(--color-emerald-500)] dark:[--reflect:--theme(--color-emerald-400)]",
|
|
122
|
+
showsImage
|
|
123
|
+
? "[--reflect-peak:30%] [--reflect-mid:12%] [--reflect-reach:56%] dark:[--reflect-peak:40%] dark:[--reflect-mid:16%] dark:[--reflect-reach:60%]"
|
|
124
|
+
: "[--reflect-peak:16%] [--reflect-mid:6%] [--reflect-reach:40%] dark:[--reflect-peak:26%] dark:[--reflect-mid:9%] dark:[--reflect-reach:46%]",
|
|
125
|
+
"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))]",
|
|
126
|
+
"transition-opacity duration-150 ease-smooth-out"
|
|
127
|
+
)}
|
|
128
|
+
data-image={showsImage ? "" : undefined}
|
|
129
|
+
data-slot="user-avatar-reflection"
|
|
130
|
+
/>
|
|
131
|
+
) : null}
|
|
132
|
+
{bezel ? (
|
|
133
|
+
// A conic highlight → shadow sweep, masked to a thin rim and rotated
|
|
134
|
+
// so the highlight faces the pointer. Rotation (not the gradient
|
|
135
|
+
// angle) is what moves, because `transform` transitions and a
|
|
136
|
+
// gradient stop does not. The highlight is white and the shadow
|
|
137
|
+
// black at alphas `--bezel-hi-a` / `--bezel-lo-a`: light and dark
|
|
138
|
+
// pick their own defaults (a white rim glares on a dark page), and a
|
|
139
|
+
// sampled photo overrides both inline so the ring is weighted against
|
|
140
|
+
// the image it actually sits on — scaled by `--bezel-hi-k`, because
|
|
141
|
+
// the rim borders the page as much as the photo and the same white
|
|
142
|
+
// that lifts a dark portrait off a light page glares on a dark one.
|
|
143
|
+
<span
|
|
144
|
+
aria-hidden="true"
|
|
145
|
+
className={cn(
|
|
146
|
+
"pointer-events-none absolute inset-0 rounded-full",
|
|
147
|
+
"[--bezel-hi-a:1] [--bezel-lo-a:0.2] dark:[--bezel-hi-a:0.18] dark:[--bezel-lo-a:0.4]",
|
|
148
|
+
"[--bezel-hi-k:1] dark:[--bezel-hi-k:0.55]",
|
|
149
|
+
"[--bezel-hi:color-mix(in_oklab,white_calc(var(--bezel-hi-a)*100%),transparent)]",
|
|
150
|
+
"[--bezel-lo:color-mix(in_oklab,black_calc(var(--bezel-lo-a)*100%),transparent)]",
|
|
151
|
+
// Starts at 6 o'clock: shadow there, highlight peaking at 12 (50%),
|
|
152
|
+
// symmetric either side — so rotate(angle) puts the highlight at
|
|
153
|
+
// exactly `bezelAngle` clockwise from 12.
|
|
154
|
+
"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%)]",
|
|
155
|
+
// Rim width is a share of the radius, not a px count, so the
|
|
156
|
+
// bezel reads the same at size-8 and size-16: solid over the outer
|
|
157
|
+
// 8%, feathering in over the 14% inside that.
|
|
158
|
+
"[mask-image:radial-gradient(farthest-side,transparent_78%,#000_92%)]",
|
|
159
|
+
"mix-blend-normal transition-transform duration-150 ease-smooth-out motion-reduce:transition-none"
|
|
160
|
+
)}
|
|
161
|
+
data-slot="user-avatar-bezel"
|
|
162
|
+
data-tone={imageTone === null ? undefined : imageTone.toFixed(2)}
|
|
163
|
+
style={{
|
|
164
|
+
transform: `rotate(${bezelAngle * BEZEL_DAMPING}deg)`,
|
|
165
|
+
...(bezelAlphas
|
|
166
|
+
? {
|
|
167
|
+
"--bezel-hi-a": `calc(${bezelAlphas.hi.toFixed(2)} * var(--bezel-hi-k))`,
|
|
168
|
+
"--bezel-lo-a": bezelAlphas.lo.toFixed(2),
|
|
169
|
+
}
|
|
170
|
+
: null),
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
) : null}
|
|
174
|
+
{presence ? (
|
|
175
|
+
<PresenceBadge
|
|
176
|
+
lightAngle={bezel ? bezelAngle * BEZEL_DAMPING : undefined}
|
|
177
|
+
presence={presence}
|
|
48
178
|
/>
|
|
49
|
-
)}
|
|
50
|
-
|
|
51
|
-
</Avatar>
|
|
179
|
+
) : null}
|
|
180
|
+
</span>
|
|
52
181
|
)
|
|
53
182
|
}
|
|
@@ -12,7 +12,9 @@ export function Fallback({
|
|
|
12
12
|
loading?: boolean
|
|
13
13
|
}) {
|
|
14
14
|
return (
|
|
15
|
-
|
|
15
|
+
// Initials scale with the disc: 42% of the avatar's width (≈ text-xl on
|
|
16
|
+
// size-12), resolved against the `@container` on the Avatar root.
|
|
17
|
+
<AvatarFallback className="text-[42cqw]">
|
|
16
18
|
{loading ? (
|
|
17
19
|
<Skeleton
|
|
18
20
|
className="size-full rounded-full"
|
|
@@ -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,57 @@ 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, and with a
|
|
45
|
+
* photo it samples the image's rim lightness (CORS permitting) to weight
|
|
46
|
+
* highlight against shadow, so the emboss reads on a dark portrait and a
|
|
47
|
+
* pale one alike. Static under reduced motion. On by default — pass
|
|
48
|
+
* `false` for a flat disc.
|
|
49
|
+
*/
|
|
50
|
+
bezel?: boolean
|
|
51
|
+
/**
|
|
52
|
+
* Name this avatar's slice of the store so `useUserAvatarState(id)` /
|
|
53
|
+
* `useUserAvatarActions(id)` can read or drive it from anywhere under
|
|
54
|
+
* `ExegiaProvider`. Unnamed avatars key off `useId` and are dropped on
|
|
55
|
+
* unmount.
|
|
56
|
+
*/
|
|
57
|
+
avatarId?: UserAvatarInstanceId
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Everything the store knows about one avatar. */
|
|
61
|
+
export interface UserAvatarState {
|
|
62
|
+
presence: UserPresence | null
|
|
63
|
+
/** Bezel light direction in degrees, clockwise from 12 o'clock. Continuous
|
|
64
|
+
* (may leave [0, 360)) so the rim's CSS rotation never spins the long way;
|
|
65
|
+
* `((a % 360) + 360) % 360` if you need it normalised. */
|
|
66
|
+
bezelAngle: number
|
|
67
|
+
imageStatus: ImageStatus
|
|
68
|
+
/** Perceived lightness of the loaded image's rim, 0 (black) → 1 (white),
|
|
69
|
+
* or `null` while unknown — no image yet, or one that could not be sampled
|
|
70
|
+
* (non-CORS host, no canvas). The bezel's highlight/shadow alphas follow
|
|
71
|
+
* it so the emboss reads on a dark photo and a pale one alike. */
|
|
72
|
+
imageTone: number | null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Write-only handles onto one avatar. Nothing here re-renders the caller. */
|
|
76
|
+
export interface UserAvatarActions {
|
|
77
|
+
setPresence: (presence: UserPresence | null) => void
|
|
78
|
+
togglePresence: () => void
|
|
79
|
+
setBezelAngle: (angle: number) => 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 UserAvatarConfig {
|
|
86
|
+
controlsPresence: boolean
|
|
29
87
|
}
|
|
30
88
|
|
|
31
89
|
export interface IAudioWaveProps {
|