@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
@@ -12,7 +12,7 @@ export function canContain(item: SidebarResource) {
12
12
 
13
13
  export function flattenResources(
14
14
  items: SidebarResource[],
15
- expanded: Set<string>,
15
+ expanded: ReadonlySet<string>,
16
16
  depth = 0,
17
17
  parentId: string | null = null
18
18
  ): FlatResource[] {
@@ -2,7 +2,12 @@ import { describe, expect, mock, test } from "bun:test";
2
2
  import { render, screen } from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
 
5
+ import { act } from "@testing-library/react";
6
+
7
+ import { AnimatedSidebarPanelContext } from "@/components/blocks/shell/utils";
5
8
  import { ProfileCardBlock } from "../profile-card-block";
9
+ import { useProfileCardActions, useProfileCardState } from "../use-profile-card-state";
10
+ import type { ProfileCardActions, ProfileCardState } from "../type";
6
11
 
7
12
  const USER = { name: "Jenny Hamilton", username: "@jennycodes" };
8
13
 
@@ -131,4 +136,193 @@ describe("ProfileCardBlock", () => {
131
136
  await new Promise((resolve) => setTimeout(resolve, 0));
132
137
  expect(onError).toHaveBeenCalled();
133
138
  });
139
+ test("variant=collapsed folds to the avatar but keeps the accessible name", () => {
140
+ const { rerender } = render(<ProfileCardBlock user={USER} variant="collapsed" />);
141
+
142
+ const trigger = screen.getByRole("button", {
143
+ name: "Jenny Hamilton @jennycodes, account menu",
144
+ });
145
+ expect(trigger.hasAttribute("data-collapsed")).toBe(true);
146
+ // Hover names the tile the way the rail's menu buttons do.
147
+ expect(trigger.getAttribute("title")).toBe("Jenny Hamilton @jennycodes");
148
+ // The avatar stays; the identity lines fold (hidden, not removed — that
149
+ // is what lets the fold animate back open).
150
+ expect(screen.getByText("JH")).toBeDefined();
151
+ const identity = trigger.querySelector('[data-slot="profile-card-identity"]');
152
+ expect(identity?.getAttribute("aria-hidden")).toBe("true");
153
+ expect(identity?.textContent).toContain("Jenny Hamilton");
154
+ // 40px tile, like the tree rail: no inset, avatar centred.
155
+ expect(trigger.className).toContain("justify-center");
156
+ expect(trigger.className).toContain("size-10");
157
+ expect(trigger.className).toContain("p-0");
158
+
159
+ rerender(<ProfileCardBlock user={USER} variant="expanded" />);
160
+ expect(trigger.hasAttribute("data-collapsed")).toBe(false);
161
+ expect(trigger.getAttribute("title")).toBeNull();
162
+ expect(identity?.getAttribute("aria-hidden")).toBeNull();
163
+ });
164
+
165
+ test("variant=collapsed still opens the menu", async () => {
166
+ const user = userEvent.setup();
167
+ render(<ProfileCardBlock user={USER} variant="collapsed" />);
168
+ await user.click(screen.getByRole("button"));
169
+ expect(await screen.findByRole("menuitem", { name: "Log out" })).toBeDefined();
170
+ });
171
+
172
+ test("follows the enclosing AnimatedPanel unless variant is set", () => {
173
+ const panel = { collapsed: true, collapsible: "icon", side: "left" } as const;
174
+ const { rerender } = render(
175
+ <AnimatedSidebarPanelContext.Provider value={panel}>
176
+ <ProfileCardBlock user={USER} />
177
+ </AnimatedSidebarPanelContext.Provider>,
178
+ );
179
+ const trigger = screen.getByRole("button");
180
+ expect(trigger.hasAttribute("data-collapsed")).toBe(true);
181
+
182
+ // An explicit prop wins over the panel.
183
+ rerender(
184
+ <AnimatedSidebarPanelContext.Provider value={panel}>
185
+ <ProfileCardBlock user={USER} variant="expanded" />
186
+ </AnimatedSidebarPanelContext.Provider>,
187
+ );
188
+ expect(trigger.hasAttribute("data-collapsed")).toBe(false);
189
+
190
+ });
191
+
192
+ test("outside any panel an unnamed card starts expanded", () => {
193
+ render(<ProfileCardBlock user={USER} />);
194
+ expect(screen.getByRole("button").hasAttribute("data-collapsed")).toBe(false);
195
+ expect(screen.getByRole("button").getAttribute("data-variant")).toBe("expanded");
196
+ });
197
+
198
+ test("defaultVariant seeds an uncontrolled card", () => {
199
+ render(<ProfileCardBlock defaultVariant="collapsed" user={USER} />);
200
+ expect(screen.getByRole("button").getAttribute("data-variant")).toBe("collapsed");
201
+ });
202
+ test("data-variant mirrors the fold and onVariantChange reports store-driven changes", () => {
203
+ const onVariantChange = mock(() => {});
204
+ let actions!: ProfileCardActions;
205
+ let state!: ProfileCardState;
206
+ function Probe() {
207
+ actions = useProfileCardActions("account");
208
+ state = useProfileCardState("account");
209
+ return null;
210
+ }
211
+ render(
212
+ <>
213
+ <ProfileCardBlock
214
+ onVariantChange={onVariantChange}
215
+ profileCardId="account"
216
+ user={USER}
217
+ />
218
+ <Probe />
219
+ </>,
220
+ );
221
+ const trigger = screen.getByRole("button");
222
+ expect(trigger.getAttribute("data-variant")).toBe("expanded");
223
+ expect(state.variant).toBe("expanded");
224
+
225
+ // Driven from outside by id — no controller, no ref.
226
+ act(() => actions.collapse());
227
+ expect(trigger.getAttribute("data-variant")).toBe("collapsed");
228
+ expect(state.variant).toBe("collapsed");
229
+ expect(onVariantChange).toHaveBeenCalledWith("collapsed");
230
+
231
+ act(() => actions.toggleVariant());
232
+ expect(trigger.getAttribute("data-variant")).toBe("expanded");
233
+ expect(onVariantChange).toHaveBeenLastCalledWith("expanded");
234
+ });
235
+
236
+ test("a controlled variant is never overwritten by the store", () => {
237
+ let actions!: ProfileCardActions;
238
+ function Probe() {
239
+ actions = useProfileCardActions("locked");
240
+ return null;
241
+ }
242
+ const onVariantChange = mock(() => {});
243
+ render(
244
+ <>
245
+ <ProfileCardBlock
246
+ onVariantChange={onVariantChange}
247
+ profileCardId="locked"
248
+ user={USER}
249
+ variant="expanded"
250
+ />
251
+ <Probe />
252
+ </>,
253
+ );
254
+ act(() => actions.collapse());
255
+ // Reported, not applied — the prop is truth.
256
+ expect(onVariantChange).toHaveBeenCalledWith("collapsed");
257
+ expect(screen.getByRole("button").getAttribute("data-variant")).toBe("expanded");
258
+ });
259
+
260
+ test("openMenu / closeMenu from outside drive the menu through the store", async () => {
261
+ let actions!: ProfileCardActions;
262
+ function Probe() {
263
+ actions = useProfileCardActions("menu");
264
+ return null;
265
+ }
266
+ render(
267
+ <>
268
+ <ProfileCardBlock profileCardId="menu" user={USER} />
269
+ <Probe />
270
+ </>,
271
+ );
272
+ act(() => actions.openMenu());
273
+ expect(await screen.findByRole("menuitem", { name: "Profile" })).toBeDefined();
274
+ act(() => actions.closeMenu());
275
+ await new Promise((r) => setTimeout(r, 300));
276
+ expect(screen.queryByRole("menuitem", { name: "Profile" })).toBeNull();
277
+ });
278
+
279
+ test("busy tracks a pending action in the store", async () => {
280
+ const user = userEvent.setup();
281
+ let resolve!: () => void;
282
+ let state!: ProfileCardState;
283
+ function Probe() {
284
+ state = useProfileCardState("busy");
285
+ return null;
286
+ }
287
+ render(
288
+ <>
289
+ <ProfileCardBlock
290
+ items={[
291
+ {
292
+ id: "slow",
293
+ label: "Slow",
294
+ onSelect: () => new Promise<void>((r) => (resolve = r)),
295
+ },
296
+ ]}
297
+ profileCardId="busy"
298
+ user={USER}
299
+ />
300
+ <Probe />
301
+ </>,
302
+ );
303
+ await user.click(screen.getByRole("button"));
304
+ await user.click(await screen.findByRole("menuitem", { name: "Slow" }));
305
+ expect(state.busy).toBe(true);
306
+ expect(state.pendingActionId).toBe("slow");
307
+ await act(async () => {
308
+ resolve();
309
+ await Promise.resolve();
310
+ });
311
+ expect(state.busy).toBe(false);
312
+ });
313
+
314
+ test("user.presence and the presence prop reach the avatar badge", () => {
315
+ const { rerender } = render(
316
+ <ProfileCardBlock user={{ ...USER, presence: "online" }} />,
317
+ );
318
+ const badge = () =>
319
+ document.querySelector('[data-slot="user-avatar-presence"]');
320
+ expect(badge()?.getAttribute("data-presence")).toBe("online");
321
+ rerender(
322
+ <ProfileCardBlock presence="offline" user={{ ...USER, presence: "online" }} />,
323
+ );
324
+ expect(badge()?.getAttribute("data-presence")).toBe("offline");
325
+ rerender(<ProfileCardBlock user={USER} />);
326
+ expect(badge()).toBeNull();
327
+ });
134
328
  });
@@ -0,0 +1,28 @@
1
+ export {
2
+ ProfileCardBlock,
3
+ defaultProfileCardItems,
4
+ } from "./profile-card-block"
5
+ export type { ProfileCardBlockProps } from "./profile-card-block"
6
+ export { useProfileCard } from "./use-profile-card"
7
+ export type { ProfileCardBinding, UseProfileCardOptions } from "./use-profile-card"
8
+ export {
9
+ useProfileCardActions,
10
+ useProfileCardState,
11
+ } from "./use-profile-card-state"
12
+ // The public atom surface. `@internal` atoms (config, handlers, projections,
13
+ // the mount seed) stay unexported on purpose.
14
+ export {
15
+ profileCardBusyAtom,
16
+ profileCardCollapsedAtom,
17
+ profileCardMenuOpenAtom,
18
+ profileCardPendingActionIdAtom,
19
+ profileCardStateAtom,
20
+ profileCardVariantAtom,
21
+ removeProfileCardInstance,
22
+ resetProfileCardAtom,
23
+ selectProfileCardActionAtom,
24
+ setProfileCardMenuOpenAtom,
25
+ setProfileCardVariantAtom,
26
+ toggleProfileCardVariantAtom,
27
+ } from "./profile-card-atom"
28
+ export type * from "./type"
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Profile card state — Jotai atom families keyed by a card id.
3
+ *
4
+ * Same layout as the tree (`components/composed/tree/tree-atom.ts`) and the
5
+ * avatar (`components/user-avatar/user-avatar-atom.ts`): in-house `keyed()`
6
+ * families per field, write-only action atoms, a composed read-only state
7
+ * atom, and `removeProfileCardInstance` to drop an id from every family.
8
+ */
9
+
10
+ import { atom } from "jotai"
11
+ import type { Getter, Setter } from "jotai"
12
+
13
+ import type {
14
+ ProfileCardAction,
15
+ ProfileCardConfig,
16
+ ProfileCardHandlers,
17
+ ProfileCardInstanceId,
18
+ ProfileCardState,
19
+ ProfileCardVariant,
20
+ } from "./type"
21
+
22
+ const DEFAULT_CONFIG: ProfileCardConfig = {
23
+ controlsVariant: false,
24
+ controlsMenuOpen: false,
25
+ }
26
+ const NO_HANDLERS: ProfileCardHandlers = {}
27
+
28
+ // ── families ───────────────────────────────────────────────────────────────
29
+
30
+ type Family<AtomType> = ((id: ProfileCardInstanceId) => AtomType) & {
31
+ remove: (id: ProfileCardInstanceId) => void
32
+ }
33
+
34
+ const families: { remove: (id: ProfileCardInstanceId) => void }[] = []
35
+
36
+ function keyed<AtomType>(create: (id: ProfileCardInstanceId) => AtomType) {
37
+ const cache = new Map<ProfileCardInstanceId, AtomType>()
38
+ const family = ((id: ProfileCardInstanceId) => {
39
+ let instance = cache.get(id)
40
+ if (instance === undefined) {
41
+ instance = create(id)
42
+ cache.set(id, instance)
43
+ }
44
+ return instance
45
+ }) as Family<AtomType>
46
+ family.remove = (id: ProfileCardInstanceId) => {
47
+ cache.delete(id)
48
+ }
49
+ families.push(family)
50
+ return family
51
+ }
52
+
53
+ function stateFamily<Value>(name: string, initialValue: Value) {
54
+ return keyed((id) => {
55
+ const instance = atom(initialValue)
56
+ instance.debugLabel = `profileCard/${id}/${name}`
57
+ return instance
58
+ })
59
+ }
60
+
61
+ function readFamily<Value>(
62
+ name: string,
63
+ read: (get: Getter, id: ProfileCardInstanceId) => Value
64
+ ) {
65
+ return keyed((id) => {
66
+ const instance = atom((get) => read(get, id))
67
+ instance.debugLabel = `profileCard/${id}/${name}`
68
+ return instance
69
+ })
70
+ }
71
+
72
+ function actionFamily<Args extends unknown[]>(
73
+ name: string,
74
+ write: (
75
+ get: Getter,
76
+ set: Setter,
77
+ id: ProfileCardInstanceId,
78
+ ...args: Args
79
+ ) => void
80
+ ) {
81
+ return keyed((id) => {
82
+ const instance = atom(null, (get, set, ...args: Args) =>
83
+ write(get, set, id, ...args)
84
+ )
85
+ instance.debugLabel = `profileCard/${id}/${name}`
86
+ return instance
87
+ })
88
+ }
89
+
90
+ // ── primitives ─────────────────────────────────────────────────────────────
91
+
92
+ export const profileCardVariantAtom = stateFamily<ProfileCardVariant>(
93
+ "variant",
94
+ "expanded"
95
+ )
96
+
97
+ export const profileCardMenuOpenAtom = stateFamily<boolean>("menuOpen", false)
98
+
99
+ export const profileCardPendingActionIdAtom = stateFamily<string | null>(
100
+ "pendingActionId",
101
+ null
102
+ )
103
+
104
+ /** @internal */
105
+ export const profileCardConfigAtom = stateFamily<ProfileCardConfig>(
106
+ "config",
107
+ DEFAULT_CONFIG
108
+ )
109
+
110
+ /** @internal */
111
+ export const profileCardHandlersAtom = stateFamily<ProfileCardHandlers>(
112
+ "handlers",
113
+ NO_HANDLERS
114
+ )
115
+
116
+ // ── derived ────────────────────────────────────────────────────────────────
117
+
118
+ export const profileCardCollapsedAtom = readFamily(
119
+ "collapsed",
120
+ (get, id) => get(profileCardVariantAtom(id)) === "collapsed"
121
+ )
122
+
123
+ export const profileCardBusyAtom = readFamily(
124
+ "busy",
125
+ (get, id) => get(profileCardPendingActionIdAtom(id)) !== null
126
+ )
127
+
128
+ /** Whole-state read for callers that want the object; the field atoms are
129
+ * cheaper for components that watch one thing. */
130
+ export const profileCardStateAtom = readFamily<ProfileCardState>(
131
+ "state",
132
+ (get, id) => ({
133
+ variant: get(profileCardVariantAtom(id)),
134
+ menuOpen: get(profileCardMenuOpenAtom(id)),
135
+ pendingActionId: get(profileCardPendingActionIdAtom(id)),
136
+ busy: get(profileCardBusyAtom(id)),
137
+ })
138
+ )
139
+
140
+ // ── actions: variant ───────────────────────────────────────────────────────
141
+
142
+ /** Reports through `onVariantChange` always; writes the store only when no
143
+ * `variant` prop controls the card — the prop is truth then. */
144
+ export const setProfileCardVariantAtom = actionFamily<
145
+ [variant: ProfileCardVariant]
146
+ >("setVariant", (get, set, id, variant) => {
147
+ if (get(profileCardVariantAtom(id)) === variant) return
148
+ if (!get(profileCardConfigAtom(id)).controlsVariant)
149
+ set(profileCardVariantAtom(id), variant)
150
+ get(profileCardHandlersAtom(id)).onVariantChange?.(variant)
151
+ })
152
+
153
+ export const toggleProfileCardVariantAtom = actionFamily<[]>(
154
+ "toggleVariant",
155
+ (get, set, id) => {
156
+ set(
157
+ setProfileCardVariantAtom(id),
158
+ get(profileCardVariantAtom(id)) === "collapsed" ? "expanded" : "collapsed"
159
+ )
160
+ }
161
+ )
162
+
163
+ // ── actions: menu ──────────────────────────────────────────────────────────
164
+
165
+ /** Inert while an `open` prop controls the menu. */
166
+ export const setProfileCardMenuOpenAtom = actionFamily<[open: boolean]>(
167
+ "setMenuOpen",
168
+ (get, set, id, open) => {
169
+ if (get(profileCardConfigAtom(id)).controlsMenuOpen) return
170
+ set(profileCardMenuOpenAtom(id), open)
171
+ }
172
+ )
173
+
174
+ // ── actions: selection ─────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Run an action's `onSelect`. A returned promise marks the card busy until it
178
+ * settles; a rejection goes to `onError` and the card leaves its busy state
179
+ * either way. Because the pending id is a store value, it survives the card
180
+ * unmounting mid-flight (sign-out typically unmounts it) — the settle just
181
+ * writes to an instance nobody renders any more.
182
+ */
183
+ export const selectProfileCardActionAtom = actionFamily<
184
+ [action: ProfileCardAction]
185
+ >("selectAction", (get, set, id, action) => {
186
+ const result = action.onSelect?.()
187
+ if (!(result instanceof Promise)) return
188
+ set(profileCardPendingActionIdAtom(id), action.id)
189
+ result
190
+ .catch((cause: unknown) => get(profileCardHandlersAtom(id)).onError?.(cause))
191
+ .finally(() => {
192
+ // Only clear our own flight — a later action may have taken over.
193
+ if (get(profileCardPendingActionIdAtom(id)) === action.id)
194
+ set(profileCardPendingActionIdAtom(id), null)
195
+ })
196
+ })
197
+
198
+ // ── internal projections ───────────────────────────────────────────────────
199
+
200
+ /** @internal Projection of the mounted component's controlled props. */
201
+ export const projectProfileCardPropsAtom = actionFamily<
202
+ [
203
+ config: ProfileCardConfig,
204
+ variant: ProfileCardVariant | undefined,
205
+ menuOpen: boolean | undefined,
206
+ ]
207
+ >("projectProps", (_get, set, id, config, variant, menuOpen) => {
208
+ set(profileCardConfigAtom(id), config)
209
+ if (config.controlsVariant && variant !== undefined)
210
+ set(profileCardVariantAtom(id), variant)
211
+ if (config.controlsMenuOpen && menuOpen !== undefined)
212
+ set(profileCardMenuOpenAtom(id), menuOpen)
213
+ })
214
+
215
+ /** @internal */
216
+ export const setProfileCardHandlersAtom = actionFamily<
217
+ [handlers: ProfileCardHandlers]
218
+ >("setHandlers", (_get, set, id, handlers) => {
219
+ set(profileCardHandlersAtom(id), handlers)
220
+ })
221
+
222
+ /** @internal Seed the uncontrolled variant on mount without firing
223
+ * `onVariantChange`. */
224
+ export const seedProfileCardVariantAtom = actionFamily<
225
+ [variant: ProfileCardVariant]
226
+ >("seedVariant", (_get, set, id, variant) => {
227
+ set(profileCardVariantAtom(id), variant)
228
+ })
229
+
230
+ // ── reset ──────────────────────────────────────────────────────────────────
231
+
232
+ export const resetProfileCardAtom = actionFamily<[]>(
233
+ "reset",
234
+ (_get, set, id) => {
235
+ set(profileCardVariantAtom(id), "expanded")
236
+ set(profileCardMenuOpenAtom(id), false)
237
+ set(profileCardPendingActionIdAtom(id), null)
238
+ set(profileCardConfigAtom(id), DEFAULT_CONFIG)
239
+ set(profileCardHandlersAtom(id), NO_HANDLERS)
240
+ }
241
+ )
242
+
243
+ /** Forget a card entirely — every family drops the key so the store can
244
+ * release its state. Call on teardown of a named instance. */
245
+ export function removeProfileCardInstance(id: ProfileCardInstanceId): void {
246
+ for (const family of families) family.remove(id)
247
+ }