@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.
Files changed (165) hide show
  1. package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
  2. package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
  3. package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
  4. package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
  5. package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
  6. package/dist-lib/components/blocks/layout.d.ts +4 -1
  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 -12
  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/__tests__/logo.test.d.ts +1 -0
  35. package/dist-lib/components/composed/logo.d.ts +34 -0
  36. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  37. package/dist-lib/components/composed/tree/constants.d.ts +4 -4
  38. package/dist-lib/components/composed/tree/index.d.ts +3 -1
  39. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  40. package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
  41. package/dist-lib/components/composed/tree/type.d.ts +116 -18
  42. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
  43. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  44. package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
  45. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  46. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  47. package/dist-lib/components/user-avatar/component.d.ts +1 -8
  48. package/dist-lib/components/user-avatar/index.d.ts +5 -0
  49. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  50. package/dist-lib/components/user-avatar/type.d.ts +53 -0
  51. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  52. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +37 -0
  53. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +64 -0
  54. package/dist-lib/components/user-avatar/utils.d.ts +22 -0
  55. package/dist-lib/index.d.ts +6 -3
  56. package/dist-lib/index.js +3732 -2670
  57. package/dist-lib/index.js.map +1 -1
  58. package/dist-lib/state/exegia-provider.d.ts +51 -0
  59. package/dist-lib/state/index.d.ts +4 -0
  60. package/dist-lib/state/store.d.ts +17 -0
  61. package/package.json +15 -12
  62. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  63. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  64. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  65. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  66. package/src/components/blocks/auth/auth-state.ts +52 -0
  67. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  68. package/src/components/blocks/layout.ts +30 -2
  69. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  70. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  71. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
  72. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  73. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  74. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  75. package/src/components/blocks/nav/sidebar/type.ts +125 -30
  76. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  77. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
  78. package/src/components/blocks/nav/sidebar/utils.ts +1 -1
  79. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  80. package/src/components/blocks/profile/index.ts +28 -0
  81. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  82. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  83. package/src/components/blocks/profile/type.ts +95 -0
  84. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  85. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  86. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  87. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  88. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  89. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  90. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  91. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  92. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  93. package/src/components/blocks/shell/index.ts +18 -13
  94. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  95. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  96. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  97. package/src/components/blocks/shell/type.ts +130 -3
  98. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  99. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  100. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  101. package/src/components/blocks/shell/utils.ts +44 -4
  102. package/src/components/composed/__tests__/logo.test.tsx +60 -0
  103. package/src/components/composed/logo.tsx +159 -0
  104. package/src/components/composed/tree/CLAUDE.md +132 -0
  105. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  106. package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
  107. package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
  108. package/src/components/composed/tree/constants.ts +4 -4
  109. package/src/components/composed/tree/index.ts +38 -0
  110. package/src/components/composed/tree/tree-atom.ts +590 -0
  111. package/src/components/composed/tree/tree-node.tsx +117 -78
  112. package/src/components/composed/tree/tree.tsx +24 -16
  113. package/src/components/composed/tree/type.ts +125 -15
  114. package/src/components/composed/tree/use-tree-dnd.ts +82 -51
  115. package/src/components/composed/tree/use-tree-state.ts +105 -0
  116. package/src/components/composed/tree/use-tree.ts +185 -184
  117. package/src/components/composed/user-avatar.tsx +11 -99
  118. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +286 -0
  119. package/src/components/user-avatar/component.tsx +151 -22
  120. package/src/components/user-avatar/fallback.tsx +3 -1
  121. package/src/components/user-avatar/index.ts +19 -0
  122. package/src/components/user-avatar/presence-badge.tsx +56 -0
  123. package/src/components/user-avatar/type.ts +58 -0
  124. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  125. package/src/components/user-avatar/use-user-avatar.ts +180 -0
  126. package/src/components/user-avatar/user-avatar-atom.ts +218 -0
  127. package/src/components/user-avatar/utils.ts +94 -0
  128. package/src/index.ts +35 -3
  129. package/src/state/exegia-provider.tsx +79 -0
  130. package/src/state/index.ts +4 -0
  131. package/src/state/store.ts +19 -0
  132. package/dist-lib/components/blocks/nav/sidebar-block.d.ts +0 -14
  133. package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +0 -3
  134. package/dist-lib/components/blocks/nav/types.d.ts +0 -60
  135. package/dist-lib/components/blocks/nav/utils.d.ts +0 -1
  136. package/dist-lib/components/blocks/shell/animated-sidebar-content.d.ts +0 -2
  137. package/dist-lib/components/blocks/shell/animated-sidebar-footer.d.ts +0 -2
  138. package/dist-lib/components/blocks/shell/animated-sidebar-group-content.d.ts +0 -2
  139. package/dist-lib/components/blocks/shell/animated-sidebar-group-label.d.ts +0 -2
  140. package/dist-lib/components/blocks/shell/animated-sidebar-group.d.ts +0 -2
  141. package/dist-lib/components/blocks/shell/animated-sidebar-header.d.ts +0 -2
  142. package/dist-lib/components/blocks/shell/animated-sidebar-menu-button.d.ts +0 -3
  143. package/dist-lib/components/blocks/shell/animated-sidebar-menu-item.d.ts +0 -2
  144. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-button.d.ts +0 -3
  145. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-item.d.ts +0 -2
  146. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub.d.ts +0 -2
  147. package/dist-lib/components/blocks/shell/animated-sidebar-menu.d.ts +0 -2
  148. package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +0 -125
  149. package/src/components/blocks/nav/sidebar-block.tsx +0 -142
  150. package/src/components/blocks/nav/sidebar-nav-row.tsx +0 -78
  151. package/src/components/blocks/nav/types.ts +0 -65
  152. package/src/components/blocks/nav/utils.ts +0 -4
  153. package/src/components/blocks/shell/animated-sidebar-content.tsx +0 -19
  154. package/src/components/blocks/shell/animated-sidebar-footer.tsx +0 -19
  155. package/src/components/blocks/shell/animated-sidebar-group-content.tsx +0 -16
  156. package/src/components/blocks/shell/animated-sidebar-group-label.tsx +0 -29
  157. package/src/components/blocks/shell/animated-sidebar-group.tsx +0 -16
  158. package/src/components/blocks/shell/animated-sidebar-header.tsx +0 -16
  159. package/src/components/blocks/shell/animated-sidebar-menu-button.tsx +0 -151
  160. package/src/components/blocks/shell/animated-sidebar-menu-item.tsx +0 -20
  161. package/src/components/blocks/shell/animated-sidebar-menu-sub-button.tsx +0 -85
  162. package/src/components/blocks/shell/animated-sidebar-menu-sub-item.tsx +0 -19
  163. package/src/components/blocks/shell/animated-sidebar-menu-sub.tsx +0 -44
  164. package/src/components/blocks/shell/animated-sidebar-menu.tsx +0 -26
  165. /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,51 @@
1
+ import { useStore } from 'jotai';
2
+ import { ExegiaStore } from './store';
3
+ /**
4
+ * The library's one and only state provider.
5
+ *
6
+ * Mount it once at the app root. Every library state hook then works
7
+ * anywhere below it — there are no per-component providers to nest.
8
+ *
9
+ * One provider suffices because library state lives in module-level Jotai
10
+ * atom families keyed by an instance id, not in a context created per
11
+ * component. A new stateful component adds atoms to that module scope; it
12
+ * never adds a provider. Consumer code therefore stays exactly as it is as
13
+ * the library grows.
14
+ *
15
+ * Without this provider the hooks still run, which is the trap: Jotai falls
16
+ * back to its implicit default store, and that store is NOT `exegiaStore`,
17
+ * so imperative access would read a different store than the one the
18
+ * components render from. Mount the provider.
19
+ *
20
+ * ```tsx
21
+ * export function App() {
22
+ * return (
23
+ * <ExegiaProvider sound>
24
+ * <Routes />
25
+ * </ExegiaProvider>
26
+ * )
27
+ * }
28
+ * ```
29
+ */
30
+ import * as React from "react";
31
+ /** Options forwarded to the library's `ThemeProvider`. */
32
+ export interface ExegiaThemeOptions {
33
+ defaultTheme?: "dark" | "light" | "system";
34
+ storageKey?: string;
35
+ disableTransitionOnChange?: boolean;
36
+ }
37
+ export interface ExegiaProviderProps {
38
+ children: React.ReactNode;
39
+ /** Store to mount. Defaults to the library's `exegiaStore`. */
40
+ store?: ExegiaStore;
41
+ /** Opt into interaction sound. Off unless the app asks for it. */
42
+ sound?: boolean;
43
+ /** Mount `ThemeProvider` with these options. Omit it and none is mounted —
44
+ * it reads `localStorage` in its state initializer, so an unconditional
45
+ * mount would break SSR consumers, and `useTheme` already throws without
46
+ * one. */
47
+ theme?: ExegiaThemeOptions;
48
+ }
49
+ export declare function ExegiaProvider({ children, store, sound, theme, }: ExegiaProviderProps): React.ReactElement;
50
+ /** The store mounted by the nearest `ExegiaProvider` above the caller. */
51
+ export declare const useExegiaStore: typeof useStore;
@@ -0,0 +1,4 @@
1
+ export { ExegiaProvider, useExegiaStore } from './exegia-provider';
2
+ export type { ExegiaProviderProps, ExegiaThemeOptions } from './exegia-provider';
3
+ export { exegiaStore } from './store';
4
+ export type { ExegiaStore } from './store';
@@ -0,0 +1,17 @@
1
+ import { createStore } from 'jotai';
2
+ /** A Jotai store as created by `createStore()`. */
3
+ export type ExegiaStore = ReturnType<typeof createStore>;
4
+ /**
5
+ * The library's default store — the one `ExegiaProvider` mounts unless it is
6
+ * handed another. Exported so non-React code (a router guard, a command
7
+ * palette, a test) can read and write library state imperatively:
8
+ *
9
+ * ```ts
10
+ * exegiaStore.get(treeExpandedIdsAtom("app-nav"))
11
+ * ```
12
+ *
13
+ * A single module-level store is wrong for SSR — state would leak between
14
+ * requests — so render-per-request apps pass a fresh `createStore()` to
15
+ * `ExegiaProvider` instead.
16
+ */
17
+ export declare const exegiaStore: ExegiaStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exegia/corpora-ui",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "shadcn-ready React UI library for the corpora manuscript-research apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,6 +23,7 @@
23
23
  "dist-lib",
24
24
  "src/components",
25
25
  "src/lib",
26
+ "src/state",
26
27
  "src/index.css",
27
28
  "src/index.ts"
28
29
  ],
@@ -37,18 +38,19 @@
37
38
  "preview": "vite preview"
38
39
  },
39
40
  "peerDependencies": {
40
- "react": "^19.0.0",
41
- "react-dom": "^19.0.0"
41
+ "jotai": "^2.20.2",
42
+ "react": "^19.2.8",
43
+ "react-dom": "^19.2.8"
42
44
  },
43
45
  "dependencies": {
44
- "@base-ui/react": "^1.6.0",
46
+ "@base-ui/react": "^1.7.0",
45
47
  "@remixicon/react": "^4.9.0",
46
48
  "class-variance-authority": "^0.7.1",
47
49
  "clsx": "^2.1.1",
48
- "cuelume": "^0.2.1",
50
+ "cuelume": "^0.2.2",
49
51
  "iconsax-reactjs": "^0.0.8",
50
52
  "lucide-react": "^1.31.0",
51
- "motion": "^12.43.0",
53
+ "motion": "^13.1.0",
52
54
  "motion-icons-react": "^1.1.0",
53
55
  "tailwind-animations": "^1.0.2",
54
56
  "tailwind-merge": "^3.6.0"
@@ -57,11 +59,11 @@
57
59
  "@eslint/js": "^10",
58
60
  "@fontsource-variable/figtree": "^5.3.0",
59
61
  "@fontsource-variable/inter": "^5.3.0",
60
- "@happy-dom/global-registrator": "^20.11.1",
62
+ "@happy-dom/global-registrator": "^20.11.2",
61
63
  "@tailwindcss/vite": "^4",
62
64
  "@testing-library/dom": "^10.4.1",
63
65
  "@testing-library/react": "^16.3.2",
64
- "@testing-library/user-event": "^14.6.1",
66
+ "@testing-library/user-event": "^14.6.5",
65
67
  "@types/bun": "^1.3.14",
66
68
  "@types/node": "^24",
67
69
  "@types/react": "^19",
@@ -69,14 +71,15 @@
69
71
  "@vitejs/plugin-react": "^6",
70
72
  "eslint": "^10",
71
73
  "eslint-plugin-react-hooks": "^7.1.1",
72
- "eslint-plugin-react-refresh": "^0.5.2",
74
+ "eslint-plugin-react-refresh": "^0.5.4",
73
75
  "globals": "^17",
74
- "prettier": "^3.8.3",
75
- "prettier-plugin-tailwindcss": "^0.8.0",
76
+ "jotai": "^2.20.2",
77
+ "prettier": "^3.9.6",
78
+ "prettier-plugin-tailwindcss": "^0.8.1",
76
79
  "react": "^19.2.6",
77
80
  "react-dom": "^19.2.6",
78
81
  "react-router": "^8.3.0",
79
- "shadcn": "^4.16.0",
82
+ "shadcn": "^4.18.0",
80
83
  "tailwindcss": "^4",
81
84
  "tw-animate-css": "^1.4.0",
82
85
  "typescript": "~6",
@@ -0,0 +1,247 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { render, screen } from "@testing-library/react"
3
+ import userEvent from "@testing-library/user-event"
4
+ import { createStore } from "jotai"
5
+
6
+ import { ExegiaProvider } from "@/state"
7
+ import {
8
+ DEFAULT_AUTH_FLOW_ID,
9
+ authFlowErrorAtom,
10
+ authFlowIdentifierAtom,
11
+ authFlowMaskedIdentifierAtom,
12
+ authFlowStateAtom,
13
+ authFlowStatusAtom,
14
+ authFlowStepAtom,
15
+ authSessionStatusAtom,
16
+ authUserAtom,
17
+ beginAuthVerificationAtom,
18
+ completeAuthFlowAtom,
19
+ endAuthSessionAtom,
20
+ failAuthFlowAtom,
21
+ goToAuthStepAtom,
22
+ isAuthenticatedAtom,
23
+ maskAuthIdentifier,
24
+ removeAuthFlowInstance,
25
+ resetAuthFlowAtom,
26
+ signInAtom,
27
+ updateAuthUserAtom,
28
+ useAuthFlow,
29
+ useAuthFlowActions,
30
+ } from "../auth-state"
31
+ import type { AuthUser } from "../auth-state"
32
+
33
+ const USER: AuthUser = {
34
+ id: "u-1",
35
+ name: "Yona Appletree",
36
+ email: "yo@example.com",
37
+ }
38
+
39
+ describe("auth flow atoms · instance isolation", () => {
40
+ test("two flow ids never share state", () => {
41
+ const store = createStore()
42
+ store.set(beginAuthVerificationAtom("modal"), {
43
+ identifier: "yo@example.com",
44
+ })
45
+
46
+ expect(store.get(authFlowStepAtom("modal"))).toBe("verify-code")
47
+ expect(store.get(authFlowStepAtom("page"))).toBe("login")
48
+ expect(store.get(authFlowIdentifierAtom("page"))).toBeNull()
49
+ })
50
+
51
+ test("two stores never share state under one id", () => {
52
+ const one = createStore()
53
+ const two = createStore()
54
+ one.set(beginAuthVerificationAtom("shared"), {
55
+ identifier: "yo@example.com",
56
+ })
57
+
58
+ expect(one.get(authFlowStepAtom("shared"))).toBe("verify-code")
59
+ expect(two.get(authFlowStepAtom("shared"))).toBe("login")
60
+ })
61
+ })
62
+
63
+ describe("auth flow atoms · flow drives the session", () => {
64
+ test("beginVerification → complete(user) drives flow and session together", () => {
65
+ const store = createStore()
66
+ const other = createStore()
67
+ store.set(beginAuthVerificationAtom(DEFAULT_AUTH_FLOW_ID), {
68
+ identifier: "+15551234567",
69
+ channel: "sms",
70
+ })
71
+ store.set(completeAuthFlowAtom(DEFAULT_AUTH_FLOW_ID), USER)
72
+
73
+ const flow = store.get(authFlowStateAtom(DEFAULT_AUTH_FLOW_ID))
74
+ expect(flow.step).toBe("success")
75
+ expect(flow.status).toBe("success")
76
+ expect(flow.error).toBeNull()
77
+ expect(flow.channel).toBe("sms")
78
+ expect(store.get(authUserAtom)).toEqual(USER)
79
+ expect(store.get(isAuthenticatedAtom)).toBe(true)
80
+
81
+ // The other store's session never moved.
82
+ expect(other.get(authSessionStatusAtom)).toBe("unknown")
83
+ expect(other.get(authUserAtom)).toBeNull()
84
+ })
85
+
86
+ test("complete() without a user leaves the session alone", () => {
87
+ const store = createStore()
88
+ store.set(completeAuthFlowAtom(DEFAULT_AUTH_FLOW_ID))
89
+
90
+ expect(store.get(authFlowStatusAtom(DEFAULT_AUTH_FLOW_ID))).toBe("success")
91
+ expect(store.get(authSessionStatusAtom)).toBe("unknown")
92
+ })
93
+ })
94
+
95
+ describe("auth flow atoms · status transitions", () => {
96
+ test("fail sets status and error; goToStep clears them", () => {
97
+ const store = createStore()
98
+ store.set(failAuthFlowAtom(DEFAULT_AUTH_FLOW_ID), "Wrong code")
99
+
100
+ expect(store.get(authFlowStatusAtom(DEFAULT_AUTH_FLOW_ID))).toBe("error")
101
+ expect(store.get(authFlowErrorAtom(DEFAULT_AUTH_FLOW_ID))).toBe(
102
+ "Wrong code"
103
+ )
104
+
105
+ store.set(goToAuthStepAtom(DEFAULT_AUTH_FLOW_ID), "forgot-password")
106
+ expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe(
107
+ "forgot-password"
108
+ )
109
+ expect(store.get(authFlowStatusAtom(DEFAULT_AUTH_FLOW_ID))).toBe("idle")
110
+ expect(store.get(authFlowErrorAtom(DEFAULT_AUTH_FLOW_ID))).toBeNull()
111
+ })
112
+ })
113
+
114
+ describe("maskAuthIdentifier", () => {
115
+ test("masks emails, keeping the first character and the domain", () => {
116
+ expect(maskAuthIdentifier("yo@example.com")).toBe("y•••@example.com")
117
+ expect(maskAuthIdentifier("y@ex.co")).toBe("y•••@ex.co")
118
+ })
119
+
120
+ test("masks phone numbers down to the last 4 characters", () => {
121
+ expect(maskAuthIdentifier("+15551234567")).toBe("•••4567")
122
+ expect(maskAuthIdentifier("123")).toBe("•••")
123
+ // Exactly 4 chars: last-4 would reveal everything — mask it all.
124
+ expect(maskAuthIdentifier("1234")).toBe("•••")
125
+ })
126
+
127
+ test("the derived atom masks the identifier in flight", () => {
128
+ const store = createStore()
129
+ expect(store.get(authFlowMaskedIdentifierAtom("m"))).toBeNull()
130
+
131
+ store.set(beginAuthVerificationAtom("m"), {
132
+ identifier: "yo@example.com",
133
+ })
134
+ expect(store.get(authFlowMaskedIdentifierAtom("m"))).toBe(
135
+ "y•••@example.com"
136
+ )
137
+ })
138
+ })
139
+
140
+ describe("auth session atoms · sign out", () => {
141
+ test("endAuthSession clears the session and resets the default flow", () => {
142
+ const store = createStore()
143
+ store.set(beginAuthVerificationAtom(DEFAULT_AUTH_FLOW_ID), {
144
+ identifier: "yo@example.com",
145
+ })
146
+ store.set(completeAuthFlowAtom(DEFAULT_AUTH_FLOW_ID), USER)
147
+
148
+ store.set(endAuthSessionAtom)
149
+ expect(store.get(authSessionStatusAtom)).toBe("unauthenticated")
150
+ expect(store.get(authUserAtom)).toBeNull()
151
+ expect(store.get(isAuthenticatedAtom)).toBe(false)
152
+
153
+ const flow = store.get(authFlowStateAtom(DEFAULT_AUTH_FLOW_ID))
154
+ expect(flow.step).toBe("login")
155
+ expect(flow.identifier).toBeNull()
156
+ expect(flow.maskedIdentifier).toBeNull()
157
+ expect(flow.status).toBe("idle")
158
+ })
159
+ })
160
+
161
+ describe("auth session atoms · updateAuthUser", () => {
162
+ test("shallow-merges a patch into the signed-in user", () => {
163
+ const store = createStore()
164
+ store.set(signInAtom, USER)
165
+ store.set(updateAuthUserAtom, { name: "Yona A.", avatar: "/a.png" })
166
+
167
+ expect(store.get(authUserAtom)).toEqual({
168
+ ...USER,
169
+ name: "Yona A.",
170
+ avatar: "/a.png",
171
+ })
172
+ })
173
+
174
+ test("is a no-op when signed out", () => {
175
+ const store = createStore()
176
+ store.set(updateAuthUserAtom, { name: "Nobody" })
177
+ expect(store.get(authUserAtom)).toBeNull()
178
+ })
179
+ })
180
+
181
+ describe("auth flow atoms · reset and teardown", () => {
182
+ test("resetAuthFlow restores the initial values", () => {
183
+ const store = createStore()
184
+ store.set(beginAuthVerificationAtom("r"), {
185
+ identifier: "+15551234567",
186
+ channel: "sms",
187
+ step: "signup",
188
+ })
189
+ store.set(failAuthFlowAtom("r"), "nope")
190
+
191
+ store.set(resetAuthFlowAtom("r"))
192
+ expect(store.get(authFlowStateAtom("r"))).toEqual({
193
+ step: "login",
194
+ identifier: null,
195
+ maskedIdentifier: null,
196
+ channel: "email",
197
+ status: "idle",
198
+ error: null,
199
+ })
200
+ })
201
+
202
+ test("removeAuthFlowInstance drops the slice back to its defaults", () => {
203
+ const store = createStore()
204
+ store.set(beginAuthVerificationAtom("gone"), {
205
+ identifier: "yo@example.com",
206
+ })
207
+
208
+ removeAuthFlowInstance("gone")
209
+ expect(store.get(authFlowStepAtom("gone"))).toBe("login")
210
+ expect(store.get(authFlowIdentifierAtom("gone"))).toBeNull()
211
+ })
212
+ })
213
+
214
+ describe("ExegiaProvider · siblings share one flow", () => {
215
+ /** Drives the flow; holds no state of its own. */
216
+ function Begin() {
217
+ const { beginVerification } = useAuthFlowActions()
218
+ return (
219
+ <button
220
+ onClick={() => beginVerification({ identifier: "yo@example.com" })}
221
+ type="button"
222
+ >
223
+ begin
224
+ </button>
225
+ )
226
+ }
227
+
228
+ /** Reads the flow; no relationship to `Begin` beyond the default id. */
229
+ function Destination() {
230
+ const flow = useAuthFlow()
231
+ return <p>{flow.maskedIdentifier ?? "no destination"}</p>
232
+ }
233
+
234
+ test("one sibling's beginVerification shows in the other's read", async () => {
235
+ const user = userEvent.setup()
236
+ render(
237
+ <ExegiaProvider store={createStore()}>
238
+ <Begin />
239
+ <Destination />
240
+ </ExegiaProvider>
241
+ )
242
+
243
+ expect(screen.getByText("no destination")).toBeDefined()
244
+ await user.click(screen.getByRole("button", { name: "begin" }))
245
+ expect(await screen.findByText("y•••@example.com")).toBeDefined()
246
+ })
247
+ })
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Per-instance Jotai state for the auth flow — which auth block the app is
3
+ * showing, the identifier in flight, and the attempt status.
4
+ *
5
+ * Every atom is a module-level family keyed by a flow id, so one
6
+ * `ExegiaProvider` at the app root is enough: a modal login and a full-page
7
+ * login can coexist in one store, and any component can drive a flow by id
8
+ * without holding a controller. Most apps have one flow and use
9
+ * `DEFAULT_AUTH_FLOW_ID`.
10
+ *
11
+ * This is a coordination layer only. The auth blocks keep passwords, codes
12
+ * and field values in local `useState` on purpose — never store those here.
13
+ *
14
+ * Import direction: `auth-session-atom.ts` is the LEAF. This module imports
15
+ * `signInAtom` from it (`completeAuthFlowAtom` signs the session in) and
16
+ * defines the composite `endAuthSessionAtom`; the session module never
17
+ * imports from here.
18
+ */
19
+ import { atom } from "jotai"
20
+ import type { Getter, Setter } from "jotai"
21
+
22
+ import { signInAtom, signOutAtom } from "./auth-session-atom"
23
+ import type { AuthStatus } from "./auth-shell"
24
+ import type {
25
+ AuthFlowChannel,
26
+ AuthFlowId,
27
+ AuthFlowState,
28
+ AuthFlowStep,
29
+ AuthUser,
30
+ BeginAuthVerificationOptions,
31
+ } from "./auth-state-type"
32
+
33
+ export const DEFAULT_AUTH_FLOW_ID: AuthFlowId = "default"
34
+
35
+ /**
36
+ * A string-keyed atom family (same in-house shape as `tree-atom.ts`:
37
+ * `jotai/utils`' `atomFamily` is deprecated for Jotai v3, and we need only
38
+ * the string-keyed case with a `remove`). Dropping a key lets the store's
39
+ * WeakMap release that instance's state.
40
+ */
41
+ type Family<AtomType> = ((id: AuthFlowId) => AtomType) & {
42
+ remove: (id: AuthFlowId) => void
43
+ }
44
+
45
+ /** Every family, so `removeAuthFlowInstance` can drop an id from all. */
46
+ const families: { remove: (id: AuthFlowId) => void }[] = []
47
+
48
+ function keyed<AtomType>(create: (id: AuthFlowId) => AtomType) {
49
+ const cache = new Map<AuthFlowId, AtomType>()
50
+ const family = ((id: AuthFlowId) => {
51
+ let instance = cache.get(id)
52
+ if (instance === undefined) {
53
+ instance = create(id)
54
+ cache.set(id, instance)
55
+ }
56
+ return instance
57
+ }) as Family<AtomType>
58
+ family.remove = (id: AuthFlowId) => {
59
+ cache.delete(id)
60
+ }
61
+ families.push(family)
62
+ return family
63
+ }
64
+
65
+ function stateFamily<Value>(name: string, initialValue: Value) {
66
+ return keyed((id) => {
67
+ const instance = atom(initialValue)
68
+ instance.debugLabel = `auth-flow/${id}/${name}`
69
+ return instance
70
+ })
71
+ }
72
+
73
+ function readFamily<Value>(
74
+ name: string,
75
+ read: (get: Getter, id: AuthFlowId) => Value
76
+ ) {
77
+ return keyed((id) => {
78
+ const instance = atom((get) => read(get, id))
79
+ instance.debugLabel = `auth-flow/${id}/${name}`
80
+ return instance
81
+ })
82
+ }
83
+
84
+ function actionFamily<Args extends unknown[]>(
85
+ name: string,
86
+ write: (get: Getter, set: Setter, id: AuthFlowId, ...args: Args) => void
87
+ ) {
88
+ return keyed((id) => {
89
+ const instance = atom(null, (get, set, ...args: Args) =>
90
+ write(get, set, id, ...args)
91
+ )
92
+ instance.debugLabel = `auth-flow/${id}/${name}`
93
+ return instance
94
+ })
95
+ }
96
+
97
+ // ── masking ──────────────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Mask an identifier for display. Email → first character of the local part
101
+ * plus `"•••"`, domain intact (`"y•••@example.com"`; a 1-char local part
102
+ * still gets `"y•••@…"`). Non-email (sms) → `"•••"` + last 4 characters;
103
+ * fewer than 4 characters masks everything as `"•••"`.
104
+ */
105
+ export function maskAuthIdentifier(identifier: string): string {
106
+ const at = identifier.indexOf("@")
107
+ if (at > 0) return `${identifier[0]}•••${identifier.slice(at)}`
108
+ // At 4 characters or fewer, "keep the last 4" would reveal the whole
109
+ // identifier while implying redaction — mask everything instead.
110
+ if (identifier.length <= 4) return "•••"
111
+ return `•••${identifier.slice(-4)}`
112
+ }
113
+
114
+ // ── primitives ───────────────────────────────────────────────────────────
115
+
116
+ export const authFlowStepAtom = stateFamily<AuthFlowStep>("step", "login")
117
+
118
+ /** An email or phone number in flight — never a password or a code. */
119
+ export const authFlowIdentifierAtom = stateFamily<string | null>(
120
+ "identifier",
121
+ null
122
+ )
123
+
124
+ export const authFlowChannelAtom = stateFamily<AuthFlowChannel>(
125
+ "channel",
126
+ "email"
127
+ )
128
+
129
+ export const authFlowStatusAtom = stateFamily<AuthStatus>("status", "idle")
130
+
131
+ export const authFlowErrorAtom = stateFamily<string | null>("error", null)
132
+
133
+ // ── derived ──────────────────────────────────────────────────────────────
134
+
135
+ /** `identifier` masked for display, `null` while nothing is in flight. */
136
+ export const authFlowMaskedIdentifierAtom = readFamily<string | null>(
137
+ "maskedIdentifier",
138
+ (get, id) => {
139
+ const identifier = get(authFlowIdentifierAtom(id))
140
+ return identifier === null ? null : maskAuthIdentifier(identifier)
141
+ }
142
+ )
143
+
144
+ export const authFlowStateAtom = readFamily<AuthFlowState>(
145
+ "state",
146
+ (get, id) => ({
147
+ step: get(authFlowStepAtom(id)),
148
+ identifier: get(authFlowIdentifierAtom(id)),
149
+ maskedIdentifier: get(authFlowMaskedIdentifierAtom(id)),
150
+ channel: get(authFlowChannelAtom(id)),
151
+ status: get(authFlowStatusAtom(id)),
152
+ error: get(authFlowErrorAtom(id)),
153
+ })
154
+ )
155
+
156
+ // ── actions ──────────────────────────────────────────────────────────────
157
+
158
+ /** Show a step with a clean slate (`"idle"`, no error). Keeps the
159
+ * identifier so "back to verify" flows don't lose the destination. */
160
+ export const goToAuthStepAtom = actionFamily<[step: AuthFlowStep]>(
161
+ "goToStep",
162
+ (_get, set, id, step) => {
163
+ set(authFlowStepAtom(id), step)
164
+ set(authFlowStatusAtom(id), "idle")
165
+ set(authFlowErrorAtom(id), null)
166
+ }
167
+ )
168
+
169
+ /** Record the identifier in flight and move to the verification step. */
170
+ export const beginAuthVerificationAtom = actionFamily<
171
+ [options: BeginAuthVerificationOptions]
172
+ >(
173
+ "beginVerification",
174
+ (_get, set, id, { identifier, channel = "email", step = "verify-code" }) => {
175
+ set(authFlowIdentifierAtom(id), identifier)
176
+ set(authFlowChannelAtom(id), channel)
177
+ set(authFlowStepAtom(id), step)
178
+ set(authFlowStatusAtom(id), "idle")
179
+ set(authFlowErrorAtom(id), null)
180
+ }
181
+ )
182
+
183
+ /** Mark the flow `"loading"` while an async attempt is in flight. */
184
+ export const beginAuthAttemptAtom = actionFamily<[]>(
185
+ "beginAttempt",
186
+ (_get, set, id) => {
187
+ set(authFlowStatusAtom(id), "loading")
188
+ set(authFlowErrorAtom(id), null)
189
+ }
190
+ )
191
+
192
+ export const failAuthFlowAtom = actionFamily<[message: string]>(
193
+ "fail",
194
+ (_get, set, id, message) => {
195
+ set(authFlowStatusAtom(id), "error")
196
+ set(authFlowErrorAtom(id), message)
197
+ }
198
+ )
199
+
200
+ /** Mark the flow `"success"` and land on the success step. When `user` is
201
+ * given, also sign the session in — one atomic write for the whole
202
+ * "verification passed" transition. */
203
+ export const completeAuthFlowAtom = actionFamily<[user?: AuthUser]>(
204
+ "complete",
205
+ (_get, set, id, user) => {
206
+ set(authFlowStatusAtom(id), "success")
207
+ set(authFlowErrorAtom(id), null)
208
+ set(authFlowStepAtom(id), "success")
209
+ if (user) set(signInAtom, user)
210
+ }
211
+ )
212
+
213
+ /** Back to the initial values (login step, no identifier, `"email"`,
214
+ * `"idle"`, no error). */
215
+ export const resetAuthFlowAtom = actionFamily<[]>("reset", (_get, set, id) => {
216
+ set(authFlowStepAtom(id), "login")
217
+ set(authFlowIdentifierAtom(id), null)
218
+ set(authFlowChannelAtom(id), "email")
219
+ set(authFlowStatusAtom(id), "idle")
220
+ set(authFlowErrorAtom(id), null)
221
+ })
222
+
223
+ /**
224
+ * Sign out and return the default flow to the login step — the one to wire
225
+ * to a logout button. `useAuthSessionActions().signOut` is this atom;
226
+ * `signOutAtom` in `auth-session-atom.ts` clears the session only.
227
+ */
228
+ export const endAuthSessionAtom = atom(null, (_get, set) => {
229
+ set(signOutAtom)
230
+ set(resetAuthFlowAtom(DEFAULT_AUTH_FLOW_ID))
231
+ })
232
+ endAuthSessionAtom.debugLabel = "auth-flow/endAuthSession"
233
+
234
+ /** Drop every atom for `id`. Call on teardown of an explicit flow id; the
235
+ * default flow usually just gets `resetAuthFlowAtom`. */
236
+ export function removeAuthFlowInstance(id: AuthFlowId): void {
237
+ for (const family of families) family.remove(id)
238
+ }