@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
@@ -1,18 +1,30 @@
1
1
  "use client"
2
2
  // beui.dev/components/motion/animated-sidebar
3
3
 
4
- import { motion } from "motion/react"
4
+ import { motion, type PanInfo } from "motion/react"
5
+ import {
6
+ useCallback,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ type KeyboardEvent,
11
+ } from "react"
5
12
  import { EASE_OUT } from "@/lib/ease.ts"
6
13
  import { cn } from "@/lib/utils"
7
14
  import type { AnimatedSidebarProps } from "./type"
8
15
  import {
9
16
  AnimatedSidebarPanelContext,
17
+ expandedWidthVar,
10
18
  SIDEBAR_MORPH_TRANSITION,
11
19
  PANEL_TRANSITION,
12
20
  REDUCED_TRANSITION,
13
- useAnimatedSidebar
21
+ useAnimatedSidebar,
14
22
  } from "./utils"
15
23
 
24
+ /** How far one arrow key moves the panel edge (shift = coarse step). */
25
+ const RESIZE_STEP = 16
26
+ const RESIZE_STEP_COARSE = 64
27
+
16
28
  export function AnimatedPanel({
17
29
  side = "left",
18
30
  variant = "sidebar",
@@ -26,21 +38,97 @@ export function AnimatedPanel({
26
38
  ...props
27
39
  }: AnimatedSidebarProps) {
28
40
  const context = useAnimatedSidebar()
41
+ const { bounds, panelWidth, resetPanelWidth, resizePanel } = context.fit
29
42
  const sideOpen = context.open[side]
30
43
  const collapsed = collapsible !== "none" && !sideOpen
31
44
  const offcanvas = collapsed && collapsible === "offcanvas"
32
- const width = offcanvas
33
- ? "0px"
45
+
46
+ // Only the expanded secondary panel is resizable: the rail is pinned to its
47
+ // own columns, and a collapsed panel has no edge left to grab.
48
+ const resizable =
49
+ side === "right" && collapsible === "offcanvas" && !collapsed
50
+
51
+ const resizeStartRef = useRef(0)
52
+ const [resizing, setResizing] = useState(false)
53
+
54
+ const baseWidth = offcanvas
55
+ ? 0
34
56
  : collapsed
35
57
  ? "var(--sidebar-width-icon)"
36
- : "var(--sidebar-width)"
58
+ : expandedWidthVar(side)
59
+ // The secondary panel's width is shell state, not panel state: the shell
60
+ // measured it, re-clamps it when the room changes, and hands back the same
61
+ // width after a collapse. Everything else rides the CSS variables, which
62
+ // also covers the server render, where nothing has been measured yet.
63
+ const width = resizable && panelWidth !== null ? panelWidth : baseWidth
64
+
65
+ const setPanelRef = useCallback(
66
+ (node: HTMLElement | null) => {
67
+ if (typeof ref === "function") ref(node)
68
+ else if (ref) ref.current = node
69
+ },
70
+ [ref]
71
+ )
72
+
73
+ const handleResizeStart = useCallback(() => {
74
+ resizeStartRef.current = panelWidth ?? bounds.min
75
+ setResizing(true)
76
+ }, [bounds.min, panelWidth])
77
+
78
+ const handleResize = useCallback(
79
+ (_event: PointerEvent, info: PanInfo) => {
80
+ // The handle rides the panel's inner edge, so the panel grows as the
81
+ // pointer travels left. `offset` is measured from the pointer-down
82
+ // point, so the edge tracks it 1:1 instead of accumulating rounding.
83
+ resizePanel(resizeStartRef.current - info.offset.x)
84
+ },
85
+ [resizePanel]
86
+ )
87
+
88
+ const handleResizeEnd = useCallback(() => setResizing(false), [])
89
+
90
+ const handleResizeKeyDown = useCallback(
91
+ (event: KeyboardEvent<HTMLDivElement>) => {
92
+ const direction =
93
+ event.key === "ArrowLeft" ? 1 : event.key === "ArrowRight" ? -1 : 0
94
+ if (!direction) return
95
+
96
+ event.preventDefault()
97
+ const step = event.shiftKey ? RESIZE_STEP_COARSE : RESIZE_STEP
98
+ resizePanel((panelWidth ?? bounds.min) + direction * step)
99
+ },
100
+ [bounds.min, panelWidth, resizePanel]
101
+ )
102
+
103
+ // The pointer leaves the 2px handle the moment the drag starts, so the
104
+ // cursor and the text selection have to be held on the document instead.
105
+ useEffect(() => {
106
+ if (!resizing) return
107
+
108
+ const { body } = document
109
+ const previousCursor = body.style.cursor
110
+ const previousUserSelect = body.style.userSelect
111
+ body.style.cursor = "col-resize"
112
+ body.style.userSelect = "none"
113
+
114
+ return () => {
115
+ body.style.cursor = previousCursor
116
+ body.style.userSelect = previousUserSelect
117
+ }
118
+ }, [resizing])
119
+
120
+ // A secondary panel exists only while the viewport can carry it beside the
121
+ // rail and the body at their own floors. Below that there is nothing to dock
122
+ // into, so the panel stands down rather than squeezing the content column —
123
+ // and its trigger stands down with it.
124
+ if (side === "right" && !context.fit.fits) return null
37
125
 
38
126
  if (context.isMobile) return <div>TODO: Create Mobile nav experience</div>
39
127
 
40
128
  return (
41
129
  <motion.aside
42
130
  {...props}
43
- ref={ref}
131
+ ref={setPanelRef}
44
132
  initial={false}
45
133
  aria-label={ariaLabel}
46
134
  data-slot="sidebar"
@@ -48,6 +136,7 @@ export function AnimatedPanel({
48
136
  data-collapsible={collapsible}
49
137
  data-variant={variant}
50
138
  data-side={side}
139
+ data-resizing={resizing ? "" : undefined}
51
140
  animate={{
52
141
  width,
53
142
  // Swallow the shell's gap-x-2 column gap while off canvas so the
@@ -57,7 +146,11 @@ export function AnimatedPanel({
57
146
  ? { marginLeft: offcanvas ? "-0.5rem" : "0rem" }
58
147
  : { marginRight: offcanvas ? "-0.5rem" : "0rem" }),
59
148
  }}
60
- transition={context.reduce ? { duration: 0 } : SIDEBAR_MORPH_TRANSITION}
149
+ // A drag has to land on the frame it happened — the morph spring would
150
+ // trail the pointer and keep settling after the handle is released.
151
+ transition={
152
+ resizing || context.reduce ? { duration: 0 } : SIDEBAR_MORPH_TRANSITION
153
+ }
61
154
  style={style}
62
155
  className={cn(
63
156
  "group/sidebar relative hidden h-full shrink-0 will-change-[width] md:block",
@@ -66,14 +159,28 @@ export function AnimatedPanel({
66
159
  className
67
160
  )}
68
161
  >
69
- {side === "right" && !collapsed && (
162
+ {resizable && (
70
163
  // The handle occupies the shell's gap-x-2 column gap. It must stay
71
164
  // outside the clipping mask below or the gap would never receive
72
165
  // hover — clipping cuts hit-testing, not just painting.
73
166
  <motion.div
74
- className="absolute inset-y-0 -left-2 z-10 w-2 cursor-col-resize"
167
+ role="separator"
168
+ aria-label={`Resize ${ariaLabel}`}
169
+ aria-orientation="vertical"
170
+ tabIndex={0}
171
+ className="absolute inset-y-0 -left-2 z-10 w-2 cursor-col-resize touch-none"
75
172
  initial="idle"
173
+ // Hover alone would drop the glow the instant the drag leaves the
174
+ // handle, so the gesture pins it open for the whole drag.
175
+ animate={resizing ? "hover" : "idle"}
76
176
  whileHover="hover"
177
+ whileFocus="hover"
178
+ onPanStart={handleResizeStart}
179
+ onPan={handleResize}
180
+ onPanEnd={handleResizeEnd}
181
+ onKeyDown={handleResizeKeyDown}
182
+ // The usual escape hatch out of a width you dragged to.
183
+ onDoubleClick={resetPanelWidth}
77
184
  >
78
185
  <motion.div
79
186
  variants={{ idle: { scaleY: 0 }, hover: { scaleY: 1 } }}
@@ -86,30 +193,20 @@ export function AnimatedPanel({
86
193
  />
87
194
  </motion.div>
88
195
  )}
89
- {/* The off canvas panel keeps its full width and slides out of the
90
- zero-width rail, so mask it to the aside's box here instead of
91
- clipping the aside itself — the aside must stay unclipped for the
92
- gap handle above to be hoverable. */}
93
- <div
94
- className={
95
- collapsible === "offcanvas"
96
- ? "absolute inset-0 overflow-hidden rounded-[inherit]"
97
- : "contents"
98
- }
99
- >
196
+ {/* The aside's own width animation is the whole layout story: the
197
+ inner panel just fills it and fades, so it is masked to the aside's
198
+ box here instead of clipping the aside itself — the aside must stay
199
+ unclipped for the gap handle above to be hoverable. */}
200
+ <div className={"contents"}>
100
201
  <motion.div
101
202
  initial={false}
102
- animate={{
103
- opacity: offcanvas ? 0 : 1,
104
- x: offcanvas ? (side === "left" ? "-100%" : "100%") : "0%",
105
- }}
203
+ animate={{ opacity: offcanvas ? 0 : 1 }}
106
204
  transition={context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION}
107
205
  className={cn(
108
- "sticky top-0 flex flex-col overflow-hidden",
109
- // collapsible === "offcanvas" && "w-(--sidebar-width)",
110
- // Pin the right panel to the aside's trailing edge so the width
111
- // animation expands the panel out of it instead of revealing a
112
- // detached strip.
206
+ "sticky top-0 flex w-full flex-col overflow-hidden",
207
+ // Pin the right panel to the aside's trailing edge so the aside's
208
+ // width animation expands the panel out of it instead of
209
+ // revealing a detached strip.
113
210
  side === "right" && "ml-auto",
114
211
 
115
212
  // Panels fill the shell's container, not the viewport — the shell
@@ -1,7 +1,25 @@
1
1
  import { ShellLayout } from "./shell-layout"
2
2
 
3
3
  export type * from "./type"
4
+ export * from "./shell-metrics"
4
5
  export * from "./use-shell-panels"
6
+ export { useShellFit } from "./use-shell-fit"
7
+ export { useShellFitActions, useShellFitState } from "./use-shell-fit-state"
8
+ // The public atom surface. `@internal` atoms (measure, mount) stay unexported
9
+ // on purpose — `export *` would make every internal a breaking-change surface
10
+ // for consumer apps.
11
+ export {
12
+ removeShellFitInstance,
13
+ resetShellPanelWidthAtom,
14
+ resizeShellPanelAtom,
15
+ shellFitFitsAtom,
16
+ shellFitMeasuredAtom,
17
+ shellFitMetricsAtom,
18
+ shellFitPanelBoundsAtom,
19
+ shellFitPanelWidthAtom,
20
+ shellFitRequestedWidthAtom,
21
+ shellFitStateAtom,
22
+ } from "./shell-fit-atom"
5
23
  export * from "./utils"
6
24
 
7
25
  export * from "./animated-panel"
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Per-instance Jotai state for the shell's layout fit.
3
+ *
4
+ * Every atom is a module-level family keyed by a shell id, so one
5
+ * `ExegiaProvider` at the app root is enough: several shells coexist in one
6
+ * store without a provider each, and a title bar, a command palette or a
7
+ * layout test can drive one by id without holding its controller.
8
+ *
9
+ * Unlike the tree, this feature has no controlled props — the shell measures
10
+ * itself, nobody hands it a viewport. So, like `blocks/auth`'s coordination
11
+ * layer, there is no config atom, no projection and no owned-* loop guard.
12
+ * `useShellFit` is the only writer of `metrics`, and that write is silent by
13
+ * design: a resize is not an event an app subscribed to.
14
+ *
15
+ * The rule itself lives in `shell-metrics.ts` and is called from here, so the
16
+ * arithmetic has one home whether it is read through a hook or a store.
17
+ */
18
+ import { atom } from "jotai"
19
+ import type { Getter, Setter } from "jotai"
20
+
21
+ import {
22
+ clampPanelWidth,
23
+ fitsPanel,
24
+ metricsEqual,
25
+ panelBounds,
26
+ type ShellMetrics,
27
+ } from "./shell-metrics"
28
+ import type {
29
+ ShellFitInstanceId,
30
+ ShellFitPanelBounds,
31
+ ShellFitSeed,
32
+ ShellFitState,
33
+ } from "./type"
34
+
35
+ /** An unmeasured shell. Zeroes read as "no layout yet", which the rule fails
36
+ * open on rather than hiding a panel over a reading it never took. */
37
+ const NO_METRICS: ShellMetrics = {
38
+ rail: 0,
39
+ insetMin: 0,
40
+ panelMin: 0,
41
+ viewport: 0,
42
+ chrome: 0,
43
+ }
44
+
45
+ /** A panel that mounts at `--panel-width`. */
46
+ const DEFAULT_SHELL_FIT_SEED: ShellFitSeed = { panelWidth: null }
47
+
48
+ const NO_BOUNDS: ShellFitPanelBounds = { min: 0, max: 0 }
49
+
50
+ /**
51
+ * A string-keyed atom family.
52
+ *
53
+ * `jotai/utils`' `atomFamily` is deprecated for Jotai v3, and we need only
54
+ * the string-keyed case with a `remove` — so this stays in-house rather than
55
+ * adding `jotai-family` as a second Jotai package to keep version-aligned.
56
+ * Dropping a key lets the store's WeakMap release that instance's state.
57
+ */
58
+ type Family<AtomType> = ((id: ShellFitInstanceId) => AtomType) & {
59
+ remove: (id: ShellFitInstanceId) => void
60
+ }
61
+
62
+ /** Every family, so `removeShellFitInstance` can drop an id from all of them. */
63
+ const families: { remove: (id: ShellFitInstanceId) => void }[] = []
64
+
65
+ function keyed<AtomType>(create: (id: ShellFitInstanceId) => AtomType) {
66
+ const cache = new Map<ShellFitInstanceId, AtomType>()
67
+ const family = ((id: ShellFitInstanceId) => {
68
+ let instance = cache.get(id)
69
+ if (instance === undefined) {
70
+ instance = create(id)
71
+ cache.set(id, instance)
72
+ }
73
+ return instance
74
+ }) as Family<AtomType>
75
+ family.remove = (id: ShellFitInstanceId) => {
76
+ cache.delete(id)
77
+ }
78
+ families.push(family)
79
+ return family
80
+ }
81
+
82
+ function stateFamily<Value>(name: string, initialValue: Value) {
83
+ return keyed((id) => {
84
+ const instance = atom(initialValue)
85
+ instance.debugLabel = `shell-fit/${id}/${name}`
86
+ return instance
87
+ })
88
+ }
89
+
90
+ function readFamily<Value>(
91
+ name: string,
92
+ read: (get: Getter, id: ShellFitInstanceId) => Value
93
+ ) {
94
+ return keyed((id) => {
95
+ const instance = atom((get) => read(get, id))
96
+ instance.debugLabel = `shell-fit/${id}/${name}`
97
+ return instance
98
+ })
99
+ }
100
+
101
+ function actionFamily<Args extends unknown[]>(
102
+ name: string,
103
+ write: (
104
+ get: Getter,
105
+ set: Setter,
106
+ id: ShellFitInstanceId,
107
+ ...args: Args
108
+ ) => void
109
+ ) {
110
+ return keyed((id) => {
111
+ const instance = atom(null, (get, set, ...args: Args) =>
112
+ write(get, set, id, ...args)
113
+ )
114
+ instance.debugLabel = `shell-fit/${id}/${name}`
115
+ return instance
116
+ })
117
+ }
118
+
119
+ /** The shell's own measurement of its columns. `useShellFit` is the only
120
+ * writer; read `shellFitMeasuredAtom` to get it with the "no layout yet" case
121
+ * already resolved. */
122
+ export const shellFitMetricsAtom = stateFamily<ShellMetrics>(
123
+ "metrics",
124
+ NO_METRICS
125
+ )
126
+
127
+ /** The width the user dragged to, before clamping — null while the panel sits
128
+ * at `--panel-width`. Read `shellFitPanelWidthAtom` for the width the panel
129
+ * actually renders at: clamping on read is what lets a narrowing viewport
130
+ * pull the panel down without forgetting the width they asked for. */
131
+ export const shellFitRequestedWidthAtom = stateFamily<number | null>(
132
+ "requestedWidth",
133
+ null
134
+ )
135
+
136
+ const shellFitSeedAtom = stateFamily<ShellFitSeed>(
137
+ "seed",
138
+ DEFAULT_SHELL_FIT_SEED
139
+ )
140
+ const shellFitInitializedAtom = stateFamily<boolean>("initialized", false)
141
+
142
+ /** The metrics, or null when the shell has no layout to report — a server
143
+ * render, a `display: none` host, a test with no layout engine. A zero is not
144
+ * a width, and treating it as one would pin the panel to nothing. */
145
+ export const shellFitMeasuredAtom = readFamily<ShellMetrics | null>(
146
+ "measured",
147
+ (get, id) => {
148
+ const metrics = get(shellFitMetricsAtom(id))
149
+ return metrics.panelMin > 0 ? metrics : null
150
+ }
151
+ )
152
+
153
+ /** Whether the shell can hold a secondary panel at all. */
154
+ export const shellFitFitsAtom = readFamily("fits", (get, id) =>
155
+ fitsPanel(get(shellFitMetricsAtom(id)))
156
+ )
157
+
158
+ /** The range a resize may land in. */
159
+ export const shellFitPanelBoundsAtom = readFamily<ShellFitPanelBounds>(
160
+ "panelBounds",
161
+ (get, id) => {
162
+ const measured = get(shellFitMeasuredAtom(id))
163
+ return measured ? panelBounds(measured) : NO_BOUNDS
164
+ }
165
+ )
166
+
167
+ /** The width the secondary panel renders at, or null before the first
168
+ * measurement, where the caller falls back to `--panel-width`. */
169
+ export const shellFitPanelWidthAtom = readFamily<number | null>(
170
+ "panelWidth",
171
+ (get, id) => {
172
+ const measured = get(shellFitMeasuredAtom(id))
173
+ if (!measured) return null
174
+ const requested = get(shellFitRequestedWidthAtom(id))
175
+ return clampPanelWidth(requested ?? measured.panelMin, measured)
176
+ }
177
+ )
178
+
179
+ /** The whole fit of one shell. This changes on every measurement, so a
180
+ * component that reads one field should subscribe to that field's atom
181
+ * instead: `useAtomValue(shellFitFitsAtom("app-shell"))`. */
182
+ export const shellFitStateAtom = readFamily<ShellFitState>(
183
+ "state",
184
+ (get, id) => ({
185
+ metrics: get(shellFitMeasuredAtom(id)),
186
+ fits: get(shellFitFitsAtom(id)),
187
+ panelWidth: get(shellFitPanelWidthAtom(id)),
188
+ bounds: get(shellFitPanelBoundsAtom(id)),
189
+ })
190
+ )
191
+
192
+ /** @internal The measurement's way in. Silent, and inert when the numbers did
193
+ * not move — a resize event that changes nothing must not re-render a shell. */
194
+ export const measureShellFitAtom = actionFamily<[metrics: ShellMetrics]>(
195
+ "measure",
196
+ (get, set, id, metrics) => {
197
+ if (metricsEqual(get(shellFitMetricsAtom(id)), metrics)) return
198
+ set(shellFitMetricsAtom(id), metrics)
199
+ }
200
+ )
201
+
202
+ /** Resize the secondary panel. Clamped on the way in, so a drag past the
203
+ * gutter parks at the bound instead of banking travel it has to give back —
204
+ * and a caller with no idea how wide the shell is can still ask for 900. */
205
+ export const resizeShellPanelAtom = actionFamily<[width: number]>(
206
+ "resizePanel",
207
+ (get, set, id, width) => {
208
+ const measured = get(shellFitMeasuredAtom(id))
209
+ set(
210
+ shellFitRequestedWidthAtom(id),
211
+ measured ? clampPanelWidth(width, measured) : width
212
+ )
213
+ }
214
+ )
215
+
216
+ /** Back to the width the shell mounted with — `defaultPanelWidth`, or
217
+ * `--panel-width` when there was none. */
218
+ export const resetShellPanelWidthAtom = actionFamily<[]>(
219
+ "resetPanelWidth",
220
+ (get, set, id) => {
221
+ set(shellFitRequestedWidthAtom(id), get(shellFitSeedAtom(id)).panelWidth)
222
+ }
223
+ )
224
+
225
+ /** @internal Seed the instance once. A seed describes the mount, not every
226
+ * render, so a `defaultPanelWidth` that arrives later never overwrites a
227
+ * width the user dragged to. */
228
+ export const mountShellFitAtom = actionFamily<[seed: ShellFitSeed]>(
229
+ "mount",
230
+ (get, set, id, seed) => {
231
+ if (get(shellFitInitializedAtom(id))) return
232
+ set(shellFitInitializedAtom(id), true)
233
+ set(shellFitSeedAtom(id), seed)
234
+ set(shellFitRequestedWidthAtom(id), seed.panelWidth)
235
+ }
236
+ )
237
+
238
+ /** Drop every atom for `id`. `useShellFit` calls this on unmount for shells it
239
+ * keyed itself; an explicit `shellId` is the app's key and outlives its
240
+ * component, so a resized panel survives a route change. */
241
+ export function removeShellFitInstance(id: ShellFitInstanceId): void {
242
+ for (const family of families) family.remove(id)
243
+ }
@@ -3,9 +3,7 @@
3
3
  import { MotionIcon } from "motion-icons-react"
4
4
  import * as React from "react"
5
5
 
6
- import {
7
- AnimatedPanel
8
- } from "./animated-panel.tsx"
6
+ import { AnimatedPanel } from "./animated-panel.tsx"
9
7
  import { cn } from "@/lib/utils"
10
8
  import type { ShellLayoutProps, ShellPanelControlProps } from "./type"
11
9
  import { TITLE_BAR_HEIGHT } from "./utils"
@@ -23,82 +21,86 @@ export function ShellLayout({
23
21
  defaultOpen,
24
22
  ...panelControlProps
25
23
  }: ShellLayoutProps): React.ReactElement {
26
-
27
24
  const background: ClassNameValue = `bg-linear-to-tr/increasing from-neutral-200 via-neutral-100 to-stone-200 dark:from-neutral-900 dark:via-neutral-950 dark:to-stone-950`
28
25
 
29
26
  // Each panel seeds its own side's initial state (`defaultOpen ?? open`);
30
27
  // an explicit `defaultOpen` record — usually from useShellPanels — wins
31
28
  // per side.
32
29
  const initialOpen: ShellPanelControlProps["defaultOpen"] = {
33
- ...(panels?.left && {
34
- left: panels.left.defaultOpen ?? true,
35
- }),
36
- ...(panels?.right && {
37
- right: panels.right.defaultOpen ?? false,
38
- }),
39
- ...defaultOpen,
40
- }
41
-
30
+ ...(panels?.left && {
31
+ left: panels.left.defaultOpen ?? true,
32
+ }),
33
+ ...(panels?.right && {
34
+ right: panels.right.defaultOpen ?? false,
35
+ }),
36
+ ...defaultOpen,
37
+ }
42
38
 
43
39
  return (
44
40
  <AnimatedPanelProvider
45
41
  {...panelControlProps}
46
42
  defaultOpen={initialOpen}
47
- className={cn("relative h-full min-h-0 pb-2", className, background)}
43
+ className={cn("relative h-full min-h-0 px-2 pb-2", className, background)}
48
44
  style={{
49
45
  paddingTop: variant === "desktop" ? TITLE_BAR_HEIGHT : 0,
50
46
  }}
51
47
  >
52
- <AnimatedPanel
53
- ariaLabel="Primary navigation"
54
- collapsible="icon"
55
- role="navigation"
56
- variant="inset"
57
- >
58
- {panels?.left?.component}
59
- </AnimatedPanel>
48
+ {panels?.left?.component && (
49
+ <AnimatedPanel
50
+ ariaLabel="Primary navigation"
51
+ collapsible="icon"
52
+ role="navigation"
53
+ variant="inset"
54
+ >
55
+ {panels?.left?.component}
56
+ </AnimatedPanel>
57
+ )}
60
58
 
61
- <AnimatedPanelInset className="min-w-24">
62
- <header className="flex h-12 items-center justify-between gap-2 border-b px-2">
63
- <div className="flex min-w-0 items-center gap-2">
64
- <AnimatedPanelTrigger>
65
- {panels?.left?.trigger ?? (
59
+ <AnimatedPanelInset>
60
+ <header className="flex h-12 flex-row! items-center justify-between gap-2 border-b px-2">
61
+ {panels?.left?.component && (
62
+ <div className="flex min-w-0 flex-1 items-center gap-2">
63
+ <AnimatedPanelTrigger side="left">
66
64
  <MotionIcon name="PanelLeft" size={24} animation="press" />
67
- )}
68
- </AnimatedPanelTrigger>
69
- </div>
70
- {header && <div className="flex flex-1 items-center">{header}</div>}
71
- <div className="flex shrink-0 items-center gap-2">
72
- <AnimatedPanelTrigger aria-label="Toggle panel" side="right">
73
- {panels?.right?.trigger ?? (
65
+ </AnimatedPanelTrigger>
66
+ </div>
67
+ )}
68
+ {header && (
69
+ <div className="flex w-full flex-1 items-center">{header}</div>
70
+ )}
71
+ {panels?.right?.component && (
72
+ <div className="flex flex-1 items-center justify-end gap-2">
73
+ <AnimatedPanelTrigger aria-label="Toggle panel" side="right">
74
74
  <MotionIcon
75
75
  className="opacity-70"
76
76
  name="PanelRight"
77
77
  size={24}
78
78
  />
79
- )}
80
- </AnimatedPanelTrigger>
81
- </div>
79
+ </AnimatedPanelTrigger>
80
+ </div>
81
+ )}
82
82
  </header>
83
83
  <div className="min-h-24 flex-1 overflow-auto">{children}</div>
84
84
  </AnimatedPanelInset>
85
85
 
86
- <AnimatedPanel
87
- ariaLabel={panels?.right?.name ?? "Secondary panel"}
88
- // Below md the panel is portal led over the page, so it carries the
89
- // surface itself; the desktop rail keeps it on the inner panel.
90
- className={cn(
91
- "mr-2 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-900",
92
- "outline-offset-0.5 border-t-3 border-white outline-neutral-100 dark:inset-ring-black",
93
- "rounded-lg shadow-md shadow-neutral-200 dark:shadow-neutral-950"
94
- )}
95
- collapsible="offcanvas"
96
- role="complementary"
97
- side="right"
98
- variant="inset"
99
- >
100
- {panels?.right?.component}
101
- </AnimatedPanel>
86
+ {panels?.right?.component && (
87
+ <AnimatedPanel
88
+ ariaLabel={panels.right.name ?? "Secondary panel"}
89
+ // Below md the panel is portal led over the page, so it carries the
90
+ // surface itself; the desktop rail keeps it on the inner panel.
91
+ className={cn(
92
+ "bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-900",
93
+ "outline-offset-0.5 border-t-3 border-white outline-neutral-100 dark:inset-ring-black",
94
+ "rounded-lg shadow-md shadow-neutral-200 dark:shadow-neutral-950"
95
+ )}
96
+ collapsible="offcanvas"
97
+ role="complementary"
98
+ side="right"
99
+ variant="inset"
100
+ >
101
+ {panels.right.component}
102
+ </AnimatedPanel>
103
+ )}
102
104
  </AnimatedPanelProvider>
103
105
  )
104
106
  }