@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,172 @@
1
+ import type { TreeNode } from "./type"
2
+
3
+ /** Depth-0 nodes read as sections only when the data actually nests three
4
+ * levels — a two-level tree keeps its top level as plain link rows. */
5
+ export function hasThreeLevels(items: readonly TreeNode[]): boolean {
6
+ return items.some((node) =>
7
+ node.children?.some((child) => (child.children?.length ?? 0) > 0)
8
+ )
9
+ }
10
+
11
+ /** Ids of every ancestor of `id` (nearest last); empty when absent. */
12
+ export function ancestorIdsOf(
13
+ items: readonly TreeNode[],
14
+ id: string
15
+ ): string[] {
16
+ const walk = (nodes: readonly TreeNode[], trail: string[]): string[] | null => {
17
+ for (const node of nodes) {
18
+ if (node.id === id) return trail
19
+ if (node.children) {
20
+ const found = walk(node.children, [...trail, node.id])
21
+ if (found) return found
22
+ }
23
+ }
24
+ return null
25
+ }
26
+ return walk(items, []) ?? []
27
+ }
28
+
29
+ /** Ids that start expanded: `defaultOpen` nodes plus every ancestor of the
30
+ * active entry, so the current location is never folded away. */
31
+ export function initialExpandedIds(
32
+ items: readonly TreeNode[],
33
+ activeId?: string
34
+ ): Set<string> {
35
+ const expanded = new Set<string>()
36
+ const walk = (nodes: readonly TreeNode[]) => {
37
+ for (const node of nodes) {
38
+ if (node.defaultOpen) expanded.add(node.id)
39
+ if (node.children) walk(node.children)
40
+ }
41
+ }
42
+ walk(items)
43
+ if (activeId !== undefined)
44
+ for (const id of ancestorIdsOf(items, activeId)) expanded.add(id)
45
+ return expanded
46
+ }
47
+
48
+ /** Ids of every node that can hold children — the set `expandAll` opens.
49
+ * In `files` an empty `children: []` still counts (it is a folder). */
50
+ export function expandableIdsOf(
51
+ items: readonly TreeNode[],
52
+ filesVariant = false
53
+ ): string[] {
54
+ const ids: string[] = []
55
+ const walk = (nodes: readonly TreeNode[]) => {
56
+ for (const node of nodes) {
57
+ const branch = filesVariant
58
+ ? node.children !== undefined
59
+ : (node.children?.length ?? 0) > 0
60
+ if (branch) ids.push(node.id)
61
+ if (node.children) walk(node.children)
62
+ }
63
+ }
64
+ walk(items)
65
+ return ids
66
+ }
67
+
68
+ /** Immutably relabel `id`. Returns the same array reference when absent. */
69
+ export function renameNode(
70
+ items: readonly TreeNode[],
71
+ id: string,
72
+ label: string
73
+ ): TreeNode[] {
74
+ return items.map((node) => {
75
+ if (node.id === id) return { ...node, label }
76
+ return node.children
77
+ ? { ...node, children: renameNode(node.children, id, label) }
78
+ : node
79
+ })
80
+ }
81
+
82
+ /** Whether `candidateId` is `id` itself or nests anywhere under it — the
83
+ * guard that keeps a folder from being dropped into its own subtree. */
84
+ export function containsNode(
85
+ items: readonly TreeNode[],
86
+ id: string,
87
+ candidateId: string
88
+ ): boolean {
89
+ if (id === candidateId) return true
90
+ const root = findNode(items, id)
91
+ if (!root?.children) return false
92
+ return findNode(root.children, candidateId) !== null
93
+ }
94
+
95
+ export function findNode(
96
+ items: readonly TreeNode[],
97
+ id: string
98
+ ): TreeNode | null {
99
+ for (const node of items) {
100
+ if (node.id === id) return node
101
+ if (node.children) {
102
+ const found = findNode(node.children, id)
103
+ if (found) return found
104
+ }
105
+ }
106
+ return null
107
+ }
108
+
109
+ /** Parent id (`null` at root) and index of `id` among its siblings. */
110
+ export function locateNode(
111
+ items: readonly TreeNode[],
112
+ id: string
113
+ ): { parentId: string | null; index: number } | null {
114
+ const walk = (
115
+ nodes: readonly TreeNode[],
116
+ parentId: string | null
117
+ ): { parentId: string | null; index: number } | null => {
118
+ for (const [index, node] of nodes.entries()) {
119
+ if (node.id === id) return { parentId, index }
120
+ if (node.children) {
121
+ const found = walk(node.children, node.id)
122
+ if (found) return found
123
+ }
124
+ }
125
+ return null
126
+ }
127
+ return walk(items, null)
128
+ }
129
+
130
+ /** Immutably lift `id` out of the tree and reinsert it under `parentId`
131
+ * (`null` = root) at `index` — the standard `onMove` reducer, exported so
132
+ * consumers don't each rebuild it. `index` addresses the sibling list
133
+ * *after* the node has been lifted out. */
134
+ export function moveNode(
135
+ items: readonly TreeNode[],
136
+ id: string,
137
+ parentId: string | null,
138
+ index: number
139
+ ): TreeNode[] {
140
+ // Dropping into the node's own subtree would orphan it — refuse.
141
+ if (parentId !== null && containsNode(items, id, parentId)) return [...items]
142
+ let moved: TreeNode | null = null
143
+ const strip = (nodes: readonly TreeNode[]): TreeNode[] =>
144
+ nodes.flatMap((node) => {
145
+ if (node.id === id) {
146
+ moved = node
147
+ return []
148
+ }
149
+ return node.children
150
+ ? [{ ...node, children: strip(node.children) }]
151
+ : [node]
152
+ })
153
+ const stripped = strip(items)
154
+ if (!moved) return [...items]
155
+ const captured = moved as TreeNode
156
+
157
+ if (parentId === null) {
158
+ const next = [...stripped]
159
+ next.splice(Math.min(index, next.length), 0, captured)
160
+ return next
161
+ }
162
+ const insert = (nodes: readonly TreeNode[]): TreeNode[] =>
163
+ nodes.map((node) => {
164
+ if (node.id === parentId) {
165
+ const children = [...(node.children ?? [])]
166
+ children.splice(Math.min(index, children.length), 0, captured)
167
+ return { ...node, children }
168
+ }
169
+ return node.children ? { ...node, children: insert(node.children) } : node
170
+ })
171
+ return insert(stripped)
172
+ }
@@ -1,98 +1,13 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
-
5
- import {
6
- Avatar,
7
- AvatarFallback,
8
- AvatarImage,
9
- type AvatarPrimitive,
10
- } from "@/components/ui/avatar"
11
- import { Skeleton } from "@/components/ui/skeleton"
12
- import { cn } from "@/lib/utils"
13
-
14
- type ImageStatus = Parameters<
15
- NonNullable<AvatarPrimitive.Image.Props["onLoadingStatusChange"]>
16
- >[0]
17
-
18
- export interface UserAvatarProps extends Omit<
19
- AvatarPrimitive.Root.Props,
20
- "children"
21
- > {
22
- /** Image URL. Without one the initials show immediately — no skeleton. */
23
- src?: string
24
- /** Drives the initials, and the alt text unless `alt` overrides it. */
25
- name?: string
26
- /** Overrides the initials derived from `name`. */
27
- initials?: string
28
- /**
29
- * Alt text for the image. Pass "" when adjacent text already names the
30
- * person — the image is then decorative.
31
- */
32
- alt?: string
33
- /**
34
- * Force the skeleton, for when the identity itself is still being fetched.
35
- * Omitted, it follows the image: a passed `src` skeletons until it resolves.
36
- */
37
- loading?: boolean
38
- }
39
-
40
- /** "Jenny Hamilton" → "JH", "luna" → "L". */
41
- export function initialsFrom(name: string): string {
42
- const parts = name.trim().split(/\s+/).filter(Boolean)
43
- if (parts.length === 0) return ""
44
- const first = parts[0]?.[0] ?? ""
45
- const last = parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : ""
46
- return (first + last).toUpperCase()
47
- }
48
-
49
1
  /**
50
- * Identity avatar: an image when one is given, initials otherwise.
51
- *
52
- * A passed `src` is assumed to be remote, so the slot holds a skeleton until
53
- * the image resolves rather than flashing initials that are about to be
54
- * replaced. A failed load settles on the initials.
2
+ * Back-compat path. The avatar moved to `components/user-avatar/` (split
3
+ * into component / atoms / hooks); this module re-exports it so existing
4
+ * imports keep resolving to the one implementation. Prefer the new path.
55
5
  */
56
- export function UserAvatar({
57
- src,
58
- name = "",
59
- initials,
60
- alt,
61
- loading: loadingProp,
62
- className,
63
- ...props
64
- }: UserAvatarProps): React.ReactElement {
65
- const [status, setStatus] = React.useState<ImageStatus>("idle")
66
- const loading =
67
- loadingProp ??
68
- (src !== undefined && (status === "idle" || status === "loading"))
69
-
70
- return (
71
- <Avatar
72
- className={cn("size-8", className)}
73
- data-loading={loading || undefined}
74
- data-slot="user-avatar"
75
- {...props}
76
- >
77
- {/* Held back while forced-loading: a loaded image would hide the
78
- fallback the skeleton lives in. */}
79
- {src === undefined || loadingProp === true ? null : (
80
- <AvatarImage
81
- alt={alt ?? name}
82
- onLoadingStatusChange={setStatus}
83
- src={src}
84
- />
85
- )}
86
- <AvatarFallback>
87
- {loading ? (
88
- <Skeleton
89
- className="size-full rounded-full"
90
- data-slot="avatar-skeleton"
91
- />
92
- ) : (
93
- (initials ?? initialsFrom(name))
94
- )}
95
- </AvatarFallback>
96
- </Avatar>
97
- )
98
- }
6
+ export {
7
+ UserAvatar,
8
+ initialsFrom,
9
+ useUserAvatar,
10
+ useUserAvatarActions,
11
+ useUserAvatarState,
12
+ } from "@/components/user-avatar"
13
+ export type { UserAvatarProps, UserPresence } from "@/components/user-avatar"
@@ -12,18 +12,18 @@ export function ComponentPreview({ entry }: { entry: RegistryEntry }) {
12
12
  const Demo = entry.preview
13
13
 
14
14
  return (
15
- <BrowserFrame title={entry.name} titleStyle="hidden">
15
+ <BrowserFrame title={entry.name} titleStyle="hidden" className="min-h-60">
16
16
  {Demo ? (
17
- <React.Suspense
18
- fallback={<p className="text-sm text-muted-foreground">Loading…</p>}
19
- >
20
- <Demo />
21
- </React.Suspense>
22
- ) : (
23
- <p className="text-sm text-muted-foreground">
24
- Preview coming soon ({entry.status}).
25
- </p>
26
- )}
17
+ <React.Suspense
18
+ fallback={<p className="text-sm text-muted-foreground">Loading…</p>}
19
+ >
20
+ <Demo />
21
+ </React.Suspense>
22
+ ) : (
23
+ <p className="text-sm text-muted-foreground">
24
+ Preview coming soon ({entry.status}).
25
+ </p>
26
+ )}
27
27
  </BrowserFrame>
28
28
  )
29
29
  }
@@ -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
+ })
@@ -0,0 +1,22 @@
1
+ import { cn } from "@/lib/utils"
2
+ import type { IAudioWaveProps } from "./type"
3
+
4
+ // `volume` is accepted but not yet wired to the ring animation.
5
+ export function AudioWave({ className, volume: _volume, children }: IAudioWaveProps) {
6
+ return (
7
+ <div
8
+ className={cn(
9
+ "group/avatar relative flex items-center justify-center",
10
+ className
11
+ )}
12
+ >
13
+ {/* Animated Story Ring */}
14
+ <div className="absolute -inset-1 animate-[spin_3s_linear_infinite] rounded-full bg-linear-to-tr from-yellow-400 via-fuchsia-500 to-violet-600 opacity-75 blur-xs transition-all duration-500 group-hover/avatar:opacity-100 group-hover/avatar:blur-sm" />
15
+
16
+ {/* Main Avatar */}
17
+ <div className="size-10 ring-2 ring-background transition-transform duration-500 group-hover/avatar:scale-95">
18
+ {children}
19
+ </div>
20
+ </div>
21
+ )
22
+ }