@exegia/corpora-ui 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/auth-state-atom.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
  3. package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
  4. package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
  5. package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
  6. package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
  7. package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
  8. package/dist-lib/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.d.ts +1 -0
  9. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
  10. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
  11. package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
  12. package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
  13. package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
  14. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +191 -29
  15. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
  16. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +27 -31
  17. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +6 -1
  18. package/dist-lib/components/blocks/profile/index.d.ts +7 -0
  19. package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
  20. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
  21. package/dist-lib/components/blocks/profile/type.d.ts +80 -0
  22. package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
  23. package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
  24. package/dist-lib/components/blocks/scaffold/constants.d.ts +5 -0
  25. package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
  26. package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
  27. package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +6 -1
  28. package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
  29. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
  30. package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
  31. package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
  32. package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
  33. package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
  34. package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
  35. package/dist-lib/components/blocks/shell/index.d.ts +4 -0
  36. package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
  37. package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
  38. package/dist-lib/components/blocks/shell/type.d.ts +115 -3
  39. package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
  40. package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
  41. package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
  42. package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
  43. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  44. package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
  45. package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
  46. package/dist-lib/components/composed/tree/constants.d.ts +35 -0
  47. package/dist-lib/components/composed/tree/index.d.ts +6 -0
  48. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  49. package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
  50. package/dist-lib/components/composed/tree/tree-node.d.ts +12 -0
  51. package/dist-lib/components/composed/tree/tree.d.ts +19 -0
  52. package/dist-lib/components/composed/tree/type.d.ts +290 -0
  53. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -0
  54. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  55. package/dist-lib/components/composed/tree/use-tree.d.ts +24 -0
  56. package/dist-lib/components/composed/tree/utils.d.ts +28 -0
  57. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  58. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  59. package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
  60. package/dist-lib/components/user-avatar/component.d.ts +3 -0
  61. package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
  62. package/dist-lib/components/user-avatar/index.d.ts +18 -0
  63. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  64. package/dist-lib/components/user-avatar/type.d.ts +71 -0
  65. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  66. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
  67. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
  68. package/dist-lib/components/user-avatar/utils.d.ts +2 -0
  69. package/dist-lib/index.d.ts +6 -2
  70. package/dist-lib/index.js +3576 -1366
  71. package/dist-lib/index.js.map +1 -1
  72. package/dist-lib/state/exegia-provider.d.ts +51 -0
  73. package/dist-lib/state/index.d.ts +4 -0
  74. package/dist-lib/state/store.d.ts +17 -0
  75. package/package.json +16 -12
  76. package/src/components/beste/piece/browser-frame.tsx +9 -6
  77. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  78. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  79. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  80. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  81. package/src/components/blocks/auth/auth-state.ts +52 -0
  82. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  83. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  84. package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
  85. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  86. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +112 -76
  87. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  88. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  89. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  90. package/src/components/blocks/nav/sidebar/type.ts +228 -29
  91. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  92. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +455 -225
  93. package/src/components/blocks/nav/sidebar/utils.ts +29 -1
  94. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  95. package/src/components/blocks/profile/index.ts +28 -0
  96. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  97. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  98. package/src/components/blocks/profile/type.ts +95 -0
  99. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  100. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  101. package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +160 -1
  102. package/src/components/blocks/scaffold/constants.ts +7 -0
  103. package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
  104. package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
  105. package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
  106. package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
  107. package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
  108. package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
  109. package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
  110. package/src/components/blocks/scaffold/scaffold-tab.tsx +45 -7
  111. package/src/components/blocks/scaffold/type.ts +28 -0
  112. package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
  113. package/src/components/blocks/scaffold/utils.ts +19 -0
  114. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  115. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  116. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  117. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  118. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  119. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  120. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  121. package/src/components/blocks/shell/index.ts +18 -0
  122. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  123. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  124. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  125. package/src/components/blocks/shell/type.ts +130 -3
  126. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  127. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  128. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  129. package/src/components/blocks/shell/utils.ts +44 -4
  130. package/src/components/composed/tree/CLAUDE.md +132 -0
  131. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  132. package/src/components/composed/tree/__tests__/tree.test.tsx +525 -0
  133. package/src/components/composed/tree/__tests__/use-tree.test.tsx +333 -0
  134. package/src/components/composed/tree/constants.ts +60 -0
  135. package/src/components/composed/tree/index.ts +51 -0
  136. package/src/components/composed/tree/tree-atom.ts +590 -0
  137. package/src/components/composed/tree/tree-context.ts +13 -0
  138. package/src/components/composed/tree/tree-node.tsx +490 -0
  139. package/src/components/composed/tree/tree.tsx +286 -0
  140. package/src/components/composed/tree/type.ts +322 -0
  141. package/src/components/composed/tree/use-tree-dnd.ts +179 -0
  142. package/src/components/composed/tree/use-tree-state.ts +105 -0
  143. package/src/components/composed/tree/use-tree.ts +307 -0
  144. package/src/components/composed/tree/utils.ts +172 -0
  145. package/src/components/composed/user-avatar.tsx +11 -96
  146. package/src/components/docs/component-preview.tsx +11 -11
  147. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
  148. package/src/components/user-avatar/audio-wave.tsx +22 -0
  149. package/src/components/user-avatar/component.tsx +152 -0
  150. package/src/components/user-avatar/fallback.tsx +26 -0
  151. package/src/components/user-avatar/index.ts +34 -0
  152. package/src/components/user-avatar/presence-badge.tsx +56 -0
  153. package/src/components/user-avatar/type.ts +85 -0
  154. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  155. package/src/components/user-avatar/use-user-avatar.ts +144 -0
  156. package/src/components/user-avatar/user-avatar-atom.ts +198 -0
  157. package/src/components/user-avatar/utils.ts +8 -0
  158. package/src/index.css +211 -153
  159. package/src/index.ts +35 -2
  160. package/src/state/exegia-provider.tsx +79 -0
  161. package/src/state/index.ts +4 -0
  162. package/src/state/store.ts +19 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The shell's layout arithmetic, as plain functions over px.
3
+ *
4
+ * Nothing here touches React or the DOM. This module IS the contract of who
5
+ * gets which column, which keeps the rule unit-testable on its own and lets
6
+ * the shell-fit atoms (`shellFitFitsAtom`, `shellFitPanelWidthAtom`) call it
7
+ * without restating it: `useShellFit` only supplies the measurements.
8
+ */
9
+
10
+ /** The px the shell lays itself out with. Every field is measured from a CSS
11
+ * variable (see `SHELL_WIDTHS`), never hard-coded, so a consumer's override
12
+ * flows straight through the rule. */
13
+ export interface ShellMetrics {
14
+ /** What the left rail occupies right now — its expanded width, its icon
15
+ * width, or 0 when there is no rail at all (or it is off canvas). */
16
+ rail: number
17
+ /** The floor the body refuses to go below. */
18
+ insetMin: number
19
+ /** The secondary panel's floor, which is also the width it opens at. */
20
+ panelMin: number
21
+ /** The viewport all three columns share. */
22
+ viewport: number
23
+ /** px the shell's own frame eats before any column gets a share: its
24
+ * padding, plus the gap between columns.
25
+ *
26
+ * Deliberately absent from `fitsPanel` — that rule is stated against the raw
27
+ * viewport — but subtracted from the resize ceiling, where ignoring it lets
28
+ * a full-width drag push the row past the shell by exactly this much. */
29
+ chrome: number
30
+ }
31
+
32
+ /** px the shell needs before a secondary panel can exist: the rail as it
33
+ * stands, plus the body and the panel at their own floors. */
34
+ export function requiredWidth({ rail, insetMin, panelMin }: ShellMetrics) {
35
+ return rail + insetMin + panelMin
36
+ }
37
+
38
+ /**
39
+ * Whether the shell can hold a secondary panel at all. Strictly `<`: a shell
40
+ * that fits its columns exactly has no room left to give one.
41
+ *
42
+ * An unmeasurable shell fails open. A server render, a `display: none` host
43
+ * or a test environment with no layout engine all report 0, and a panel must
44
+ * never disappear over a reading the layout could not produce.
45
+ */
46
+ export function fitsPanel(metrics: ShellMetrics) {
47
+ if (metrics.viewport <= 0 || metrics.panelMin <= 0) return true
48
+ return requiredWidth(metrics) < metrics.viewport
49
+ }
50
+
51
+ /** How wide the secondary panel may be: never under its own floor, never past
52
+ * the slack the body holds above its floor. `max` never drops below `min`, so
53
+ * a shell that does not fit reports a degenerate range instead of an inverted
54
+ * one — `fitsPanel` is what hides the panel, not a negative bound. */
55
+ export function panelBounds(metrics: ShellMetrics) {
56
+ const { chrome, insetMin, panelMin, rail, viewport } = metrics
57
+ return {
58
+ min: panelMin,
59
+ max: Math.max(panelMin, viewport - chrome - rail - insetMin),
60
+ }
61
+ }
62
+
63
+ export function clampPanelWidth(width: number, metrics: ShellMetrics) {
64
+ const { min, max } = panelBounds(metrics)
65
+ return Math.min(Math.max(width, min), max)
66
+ }
67
+
68
+ /** Resizes fire per pointer move and per resize event, most of them landing
69
+ * on the same numbers — comparing fields keeps those from re-rendering the
70
+ * whole shell. */
71
+ export function metricsEqual(a: ShellMetrics, b: ShellMetrics) {
72
+ return (
73
+ a.rail === b.rail &&
74
+ a.insetMin === b.insetMin &&
75
+ a.panelMin === b.panelMin &&
76
+ a.viewport === b.viewport &&
77
+ a.chrome === b.chrome
78
+ )
79
+ }
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react"
1
+ import type { ReactNode, RefObject } from "react"
2
2
 
3
3
  import {
4
4
  type ButtonHTMLAttributes,
@@ -7,6 +7,83 @@ import {
7
7
  } from "react"
8
8
  import type { HTMLMotionProps } from "motion/react"
9
9
 
10
+ import type { ShellMetrics } from "./shell-metrics"
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Shell fit — the shell's measurement of itself, kept in Jotai atoms keyed by
14
+ // `shellId` (see `shell-fit-atom.ts`). No atom imports here: this file stays
15
+ // dependency-free so the types can travel without the store.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** The key a shell's fit state is filed under. Pass your own to reach it from
19
+ * anywhere (`useShellFitState("app-shell")`); leave it out and the provider
20
+ * generates one that dies with it. */
21
+ export type ShellFitInstanceId = string
22
+
23
+ /** The range a secondary-panel resize may land in, in px. */
24
+ export interface ShellFitPanelBounds {
25
+ min: number
26
+ max: number
27
+ }
28
+
29
+ /** What the shell measured of itself — readable by id through
30
+ * `useShellFitState`. */
31
+ export interface ShellFitState {
32
+ /** The px the shell last measured, or null before its first measurement
33
+ * (a server render, a host with no layout). */
34
+ metrics: ShellMetrics | null
35
+ /** Whether the shell can hold a secondary panel. True while unmeasured, so
36
+ * a panel never flickers out over a reading that has not happened yet. */
37
+ fits: boolean
38
+ /** The secondary panel's width in px, already clamped to `bounds` — null
39
+ * before the first measurement, where the panel falls back to
40
+ * `--panel-width`. */
41
+ panelWidth: number | null
42
+ /** The range a resize may land in. */
43
+ bounds: ShellFitPanelBounds
44
+ }
45
+
46
+ /** Writes only — drive a shell's secondary panel by id through
47
+ * `useShellFitActions` without re-rendering when it moves. */
48
+ export interface ShellFitActions {
49
+ /** Resize the secondary panel. Clamped on the way in, so a drag past the
50
+ * gutter parks at the bound instead of banking travel it has to give back. */
51
+ resizePanel: (width: number) => void
52
+ /** Back to the width the shell mounted with — `defaultPanelWidth`, or
53
+ * `--panel-width` when there was none. */
54
+ resetPanelWidth: () => void
55
+ }
56
+
57
+ /** What `useShellFit` hands the provider: the state, the actions and the id
58
+ * they are filed under. */
59
+ export interface ShellFitController extends ShellFitState, ShellFitActions {
60
+ shellId: ShellFitInstanceId
61
+ }
62
+
63
+ export interface UseShellFitOptions {
64
+ /** File the fit under this id so it can be read elsewhere and outlive the
65
+ * shell. Generated (and dropped on unmount) when omitted. */
66
+ shellId?: ShellFitInstanceId
67
+ /** The element the shell's CSS variables live on (the provider wrapper). */
68
+ hostRef: RefObject<HTMLElement | null>
69
+ /** Whether the left rail is expanded right now — a render input, so a fold
70
+ * counts the moment React commits it rather than a frame later when the
71
+ * width animation has moved. */
72
+ railOpen: boolean
73
+ /** The width the secondary panel opens at, in px, instead of
74
+ * `--panel-width`. Read once, on mount. */
75
+ defaultPanelWidth?: number
76
+ /** Fired whenever a measurement lands on "no room": the shell uses it to
77
+ * retire the panel's open state instead of parking it. */
78
+ onUnfit?: () => void
79
+ }
80
+
81
+ /** @internal What an instance starts from, restored by `resetShellPanelWidthAtom`. */
82
+ export interface ShellFitSeed {
83
+ /** The requested panel width at mount — null for `--panel-width`. */
84
+ panelWidth: number | null
85
+ }
86
+
10
87
  export interface ShellAction {
11
88
  id: string
12
89
  label: string
@@ -60,14 +137,24 @@ export type ShellPanelControlProps = Pick<
60
137
  | "openMobile"
61
138
  | "defaultOpenMobile"
62
139
  | "onOpenMobileChange"
140
+ | "shellId"
141
+ | "defaultPanelWidth"
63
142
  >
64
143
 
65
144
  export interface UseShellPanelsOptions {
145
+ /** The id the shell's fit state is filed under. Name it to read the same
146
+ * shell from elsewhere (`useShellFitState("app-shell")`) and to keep a
147
+ * dragged panel width across a route change; otherwise the hook generates
148
+ * one that dies with it. */
149
+ shellId?: ShellFitInstanceId
66
150
  /** Initial desktop open state per side, merged over
67
151
  * `{ left: true, right: false }`. */
68
152
  defaultOpen?: AnimatedSidebarProviderProps["defaultOpen"]
69
153
  /** Initial mobile overlay state per side — every side starts closed. */
70
154
  defaultOpenMobile?: AnimatedSidebarProviderProps["defaultOpenMobile"]
155
+ /** The width the secondary panel opens at, in px, instead of
156
+ * `--panel-width`. */
157
+ defaultPanelWidth?: number
71
158
  /** Panel change event returning both the next open state and the side of
72
159
  * the panel the change comes from. Mobile overlay changes report the same
73
160
  * side as their desktop counterpart. */
@@ -75,14 +162,32 @@ export interface UseShellPanelsOptions {
75
162
  }
76
163
 
77
164
  export interface ShellPanelControls {
165
+ /** The id the shell's fit state is filed under — hand it to
166
+ * `useShellFitState` / `useShellFitActions` anywhere below `ExegiaProvider`. */
167
+ shellId: ShellFitInstanceId
168
+ /** The viewport cannot hold the secondary panel beside the rail and the
169
+ * body at their floors, so the shell has dropped the right panel and its
170
+ * trigger — UI outside the shell should stand down with them. Read straight
171
+ * out of the shell's fit atoms; it is only ever `true` once the shell is
172
+ * mounted and measured. */
173
+ isNarrow: boolean
174
+ /** The secondary panel's current width in px, or null until the shell has
175
+ * measured itself (the panel then sits at `--panel-width`). */
176
+ panelWidth: number | null
177
+ /** Resize the secondary panel from outside the shell — clamped to the room
178
+ * the shell has. */
179
+ resizePanel: (width: number) => void
78
180
  /** Live desktop open state, keyed by side. */
79
181
  open: Record<SidebarSide, boolean>
80
182
  /** Live mobile overlay state, keyed by side. */
81
183
  openMobile: Record<SidebarSide, boolean>
184
+ /** Refuses to OPEN the right panel while `isNarrow` — there is nothing on
185
+ * screen to open. Closing it always goes through. */
82
186
  setOpen: (open: boolean, side: SidebarSide) => void
83
187
  setOpenMobile: (open: boolean, side: SidebarSide) => void
84
188
  /** Desktop-only convenience — the in-shell triggers already pick the
85
- * mobile state themselves when the viewport is narrow. */
189
+ * mobile state themselves when the viewport is narrow. Carries the same
190
+ * `isNarrow` refusal as `setOpen`. */
86
191
  toggle: (side: SidebarSide) => void
87
192
  /** Spread onto ShellLayout (or AnimatedPanelProvider directly). */
88
193
  providerProps: ShellPanelControlProps
@@ -98,7 +203,6 @@ export interface ShellLayoutProps extends ShellPanelControlProps {
98
203
  variant?: "web" | "desktop"
99
204
  }
100
205
 
101
-
102
206
  export type SidebarSide = "left" | "right"
103
207
  /** Open flags keyed by the side. Sides left out stay uncontrolled / at their
104
208
  * default — there is no per-side prop, the record IS the API. */
@@ -108,6 +212,10 @@ export type SidebarCollapsible = "offcanvas" | "icon" | "none"
108
212
 
109
213
  export interface AnimatedSidebarContextValue {
110
214
  isMobile: boolean
215
+ /** What the shell measured of itself: whether it can hold a secondary panel
216
+ * at all, how wide that panel is, and the range a resize may land in. The
217
+ * right panel and its trigger stand down when `fit.fits` is false. */
218
+ fit: ShellFitController
111
219
  layoutId: string
112
220
  /** Desktop open state, keyed by side. */
113
221
  open: Record<SidebarSide, boolean>
@@ -122,6 +230,14 @@ export interface AnimatedSidebarContextValue {
122
230
  }
123
231
 
124
232
  export interface AnimatedSidebarProviderProps extends HTMLAttributes<HTMLDivElement> {
233
+ /** The id this shell's fit state (whether a secondary panel fits, how wide
234
+ * it is) is filed under in the store. Name it to read or drive the shell
235
+ * from elsewhere and to keep a dragged width across a route change; omit it
236
+ * and the provider generates one that is dropped on unmount. */
237
+ shellId?: ShellFitInstanceId
238
+ /** The width the secondary panel opens at, in px, instead of
239
+ * `--panel-width`. Read once, on mount. */
240
+ defaultPanelWidth?: number
125
241
  /** Controlled desktop open state, keyed by the side. A side left undefined
126
242
  * stays uncontrolled. */
127
243
  open?: SidebarOpenState
@@ -133,13 +249,24 @@ export interface AnimatedSidebarProviderProps extends HTMLAttributes<HTMLDivElem
133
249
  /** Initial mobile overlay state — every side starts closed. */
134
250
  defaultOpenMobile?: SidebarOpenState
135
251
  onOpenMobileChange?: (open: boolean, side: SidebarSide) => void
252
+ /** Fires when the shell crosses the width a secondary panel needs (rail +
253
+ * body + panel at their floors). An imperative escape hatch for a consumer
254
+ * that mounts the provider on its own; anything under `ExegiaProvider` can
255
+ * subscribe by id instead with `useShellFitState(shellId).fits`. */
256
+ onNarrowChange?: (isNarrow: boolean) => void
136
257
  style?: SidebarProviderStyle
137
258
  }
138
259
 
139
260
  export type SidebarProviderStyle = CSSProperties & {
261
+ /** Left rail, expanded. */
140
262
  "--sidebar-width"?: string
263
+ /** Left rail, folded to icons. */
141
264
  "--sidebar-width-icon"?: string
142
265
  "--sidebar-width-mobile"?: string
266
+ /** The secondary panel's floor, and the width it opens at. */
267
+ "--panel-width"?: string
268
+ /** The body's floor — the secondary panel may never squeeze it past this. */
269
+ "--inset-min-width"?: string
143
270
  }
144
271
 
145
272
  export type AnimatedSidebarInsetProps = HTMLMotionProps<"main">
@@ -0,0 +1,49 @@
1
+ "use client"
2
+
3
+ import { useMemo } from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
5
+
6
+ import {
7
+ resetShellPanelWidthAtom,
8
+ resizeShellPanelAtom,
9
+ shellFitStateAtom,
10
+ } from "./shell-fit-atom"
11
+ import type { ShellFitActions, ShellFitInstanceId, ShellFitState } from "./type"
12
+
13
+ /**
14
+ * Read the fit of the shell registered under `shellId` from anywhere below
15
+ * `ExegiaProvider` — no controller, no props, no provider of its own.
16
+ *
17
+ * ```tsx
18
+ * const { fits, panelWidth } = useShellFitState("app-shell")
19
+ * ```
20
+ *
21
+ * This returns the whole state object, so the caller re-renders on every
22
+ * measurement. A component that reads one field should subscribe to that
23
+ * field's atom instead: `useAtomValue(shellFitFitsAtom("app-shell"))`.
24
+ */
25
+ export function useShellFitState(shellId: ShellFitInstanceId): ShellFitState {
26
+ return useAtomValue(shellFitStateAtom(shellId))
27
+ }
28
+
29
+ /**
30
+ * Drive the secondary panel of the shell registered under `shellId` from
31
+ * anywhere. Writes only — the caller never re-renders when the shell moves,
32
+ * so this is what a command palette or a keyboard shortcut should reach for.
33
+ *
34
+ * ```tsx
35
+ * const shell = useShellFitActions("app-shell")
36
+ * <Button onClick={() => shell.resizePanel(480)}>Wide inspector</Button>
37
+ * ```
38
+ */
39
+ export function useShellFitActions(
40
+ shellId: ShellFitInstanceId
41
+ ): ShellFitActions {
42
+ const resizePanel = useSetAtom(resizeShellPanelAtom(shellId))
43
+ const resetPanelWidth = useSetAtom(resetShellPanelWidthAtom(shellId))
44
+
45
+ return useMemo(
46
+ () => ({ resizePanel, resetPanelWidth }),
47
+ [resizePanel, resetPanelWidth]
48
+ )
49
+ }
@@ -0,0 +1,135 @@
1
+ "use client"
2
+
3
+ import { useEffect, useId, useMemo, useRef, useState } from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
5
+ import { useIsomorphicLayoutEffect } from "motion/react"
6
+
7
+ import {
8
+ measureShellFitAtom,
9
+ mountShellFitAtom,
10
+ removeShellFitInstance,
11
+ resetShellPanelWidthAtom,
12
+ resizeShellPanelAtom,
13
+ shellFitStateAtom,
14
+ } from "./shell-fit-atom"
15
+ import { fitsPanel, type ShellMetrics } from "./shell-metrics"
16
+ import type {
17
+ ShellFitController,
18
+ ShellFitSeed,
19
+ UseShellFitOptions,
20
+ } from "./type"
21
+ import { resolveLength } from "./utils"
22
+
23
+ /** Read the shell's columns out of the DOM. The widths come from CSS
24
+ * variables; the rail's share comes from its own state, not its box, so a
25
+ * fold counts the moment React commits it rather than a frame later when the
26
+ * width animation has moved. */
27
+ function readMetrics(host: HTMLElement, railOpen: boolean): ShellMetrics {
28
+ // Whether a shell has a rail at all is the caller's composition, so it is
29
+ // read off the DOM rather than tracked in state.
30
+ const rail = host.querySelector<HTMLElement>(
31
+ '[data-slot="sidebar"][data-side="left"]'
32
+ )
33
+ const collapsible = rail?.dataset.collapsible
34
+
35
+ const frame = getComputedStyle(host)
36
+ const gap = Number.parseFloat(frame.columnGap) || 0
37
+
38
+ return {
39
+ rail: !rail
40
+ ? 0
41
+ : railOpen || collapsible === "none"
42
+ ? resolveLength(host, "var(--sidebar-width)")
43
+ : collapsible === "offcanvas"
44
+ ? 0
45
+ : resolveLength(host, "var(--sidebar-width-icon)"),
46
+ insetMin: resolveLength(host, "var(--inset-min-width)"),
47
+ panelMin: resolveLength(host, "var(--panel-width)"),
48
+ viewport: window.innerWidth,
49
+ chrome:
50
+ (Number.parseFloat(frame.paddingLeft) || 0) +
51
+ (Number.parseFloat(frame.paddingRight) || 0) +
52
+ gap * Math.max(0, host.children.length - 1),
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Measures the shell and decides what the secondary panel may do: whether it
58
+ * exists at all, and how wide it may be dragged.
59
+ *
60
+ * The three things that move the answer are all observed here — the viewport
61
+ * (`resize`), the rail's fold (`railOpen`, a render input) and the user's own
62
+ * drag (`resizePanel`) — so no caller has to re-derive it. The measurement is
63
+ * the only thing this hook keeps to itself: the numbers land in the shell-fit
64
+ * atoms keyed by `shellId`, so anything under `ExegiaProvider` can read the
65
+ * verdict (`useShellFitState`) or move the panel (`useShellFitActions`)
66
+ * without holding this controller.
67
+ */
68
+ export function useShellFit({
69
+ shellId: explicitId,
70
+ hostRef,
71
+ railOpen,
72
+ defaultPanelWidth,
73
+ onUnfit,
74
+ }: UseShellFitOptions): ShellFitController {
75
+ // A generated key isolates unnamed shells from each other; an explicit
76
+ // `shellId` is the app's handle on this one.
77
+ const generatedId = useId()
78
+ const shellId = explicitId ?? generatedId
79
+
80
+ // Read once: `defaultPanelWidth` describes the mount, not every render.
81
+ const [seed] = useState<ShellFitSeed>(() => ({
82
+ panelWidth: defaultPanelWidth ?? null,
83
+ }))
84
+
85
+ const mount = useSetAtom(mountShellFitAtom(shellId))
86
+ const measure = useSetAtom(measureShellFitAtom(shellId))
87
+ const resizePanel = useSetAtom(resizeShellPanelAtom(shellId))
88
+ const resetPanelWidth = useSetAtom(resetShellPanelWidthAtom(shellId))
89
+
90
+ // Before paint, and before the first measurement below, so a seeded width
91
+ // is in the store by the time the panel first reads its width.
92
+ useIsomorphicLayoutEffect(() => {
93
+ mount(seed)
94
+ }, [mount, seed])
95
+
96
+ // The listener below is bound once per fold, so the callback rides a ref
97
+ // rather than rebinding it whenever a consumer passes a fresh arrow.
98
+ const onUnfitRef = useRef(onUnfit)
99
+ useIsomorphicLayoutEffect(() => {
100
+ onUnfitRef.current = onUnfit
101
+ })
102
+
103
+ useIsomorphicLayoutEffect(() => {
104
+ const host = hostRef.current
105
+ if (!host) return
106
+
107
+ const takeMeasurement = () => {
108
+ const next = readMetrics(host, railOpen)
109
+ measure(next)
110
+ if (!fitsPanel(next)) onUnfitRef.current?.()
111
+ }
112
+
113
+ // Measured before paint so a shell that cannot hold the panel never
114
+ // flashes one, and re-measured on every fold because the rail's column is
115
+ // part of the room the panel needs.
116
+ takeMeasurement()
117
+ window.addEventListener("resize", takeMeasurement)
118
+ return () => window.removeEventListener("resize", takeMeasurement)
119
+ }, [hostRef, railOpen, measure])
120
+
121
+ // A shell the hook keyed is scrap once its component goes. An explicit
122
+ // `shellId` is the app's key and outlives the mount — that is what lets a
123
+ // dragged width survive a route change.
124
+ useEffect(() => {
125
+ if (explicitId !== undefined) return
126
+ return () => removeShellFitInstance(shellId)
127
+ }, [shellId, explicitId])
128
+
129
+ const state = useAtomValue(shellFitStateAtom(shellId))
130
+
131
+ return useMemo<ShellFitController>(
132
+ () => ({ shellId, ...state, resizePanel, resetPanelWidth }),
133
+ [shellId, state, resizePanel, resetPanelWidth]
134
+ )
135
+ }
@@ -1,7 +1,14 @@
1
1
  "use client"
2
2
 
3
- import { useCallback, useMemo, useState } from "react"
3
+ import { useCallback, useEffect, useId, useMemo, useState } from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
4
5
 
6
+ import {
7
+ removeShellFitInstance,
8
+ resizeShellPanelAtom,
9
+ shellFitFitsAtom,
10
+ shellFitPanelWidthAtom,
11
+ } from "./shell-fit-atom"
5
12
  import type {
6
13
  ShellPanelControlProps,
7
14
  ShellPanelControls,
@@ -15,12 +22,36 @@ import type {
15
22
  * callback. Spread the returned `providerProps` onto ShellLayout; the
16
23
  * setters and `toggle` are for UI that lives outside the shell (title-bar
17
24
  * buttons, command palette, shortcuts).
25
+ *
26
+ * The way back down is the store: `providerProps` carries a `shellId`, the
27
+ * shell files its measurement under it, and this hook reads `isNarrow` and
28
+ * `panelWidth` straight out of those atoms — so outside UI stands down with
29
+ * the panel instead of measuring `--sidebar-width` a second time. Name the
30
+ * `shellId` and any component below `ExegiaProvider` can do the same with
31
+ * `useShellFitState(shellId)`.
32
+ *
33
+ * Call it under the same `ExegiaProvider` as the shell it drives (or under
34
+ * none at all, on both sides): mounted above the provider it would read
35
+ * Jotai's default store while the shell writes to the provider's.
18
36
  */
19
37
  export function useShellPanels({
38
+ shellId: explicitId,
20
39
  defaultOpen,
21
40
  defaultOpenMobile,
41
+ defaultPanelWidth,
22
42
  onPanelChange,
23
43
  }: UseShellPanelsOptions = {}): ShellPanelControls {
44
+ // The hook, not the provider, keys the shell: it renders above the provider
45
+ // and has to read the same atoms the provider writes. Whoever generates the
46
+ // key drops it — the provider treats a passed-in id as the app's and leaves
47
+ // it alone on unmount.
48
+ const generatedId = useId()
49
+ const shellId = explicitId ?? generatedId
50
+ useEffect(() => {
51
+ if (explicitId !== undefined) return
52
+ return () => removeShellFitInstance(shellId)
53
+ }, [shellId, explicitId])
54
+
24
55
  const [open, setOpenState] = useState<Record<SidebarSide, boolean>>(() => ({
25
56
  left: defaultOpen?.left ?? true,
26
57
  right: defaultOpen?.right ?? false,
@@ -32,24 +63,40 @@ export function useShellPanels({
32
63
  right: defaultOpenMobile?.right ?? false,
33
64
  }))
34
65
 
66
+ // Reads false until the shell has measured itself — an unmeasured shell
67
+ // fails open, so nothing out here stands down over a reading that has not
68
+ // happened yet.
69
+ const fits = useAtomValue(shellFitFitsAtom(shellId))
70
+ const isNarrow = !fits
71
+ const panelWidth = useAtomValue(shellFitPanelWidthAtom(shellId))
72
+ const resizePanel = useSetAtom(resizeShellPanelAtom(shellId))
73
+
74
+ // The shell drops the right panel when the viewport cannot hold it, so
75
+ // opening it from out here would only surface later as a panel nobody
76
+ // asked for. Closing always goes through — that is how state left over
77
+ // from a wider viewport clears.
35
78
  const setOpen = useCallback(
36
79
  (nextOpen: boolean, side: SidebarSide) => {
80
+ if (nextOpen && side === "right" && isNarrow) return
81
+
37
82
  setOpenState((prev) =>
38
83
  prev[side] === nextOpen ? prev : { ...prev, [side]: nextOpen }
39
84
  )
40
85
  onPanelChange?.(nextOpen, side)
41
86
  },
42
- [onPanelChange]
87
+ [isNarrow, onPanelChange]
43
88
  )
44
89
 
45
90
  const setOpenMobile = useCallback(
46
91
  (nextOpen: boolean, side: SidebarSide) => {
92
+ if (nextOpen && side === "right" && isNarrow) return
93
+
47
94
  setOpenMobileState((prev) =>
48
95
  prev[side] === nextOpen ? prev : { ...prev, [side]: nextOpen }
49
96
  )
50
97
  onPanelChange?.(nextOpen, side)
51
98
  },
52
- [onPanelChange]
99
+ [isNarrow, onPanelChange]
53
100
  )
54
101
 
55
102
  const toggle = useCallback(
@@ -59,15 +106,21 @@ export function useShellPanels({
59
106
 
60
107
  const providerProps = useMemo<ShellPanelControlProps>(
61
108
  () => ({
109
+ shellId,
110
+ defaultPanelWidth,
62
111
  open,
63
112
  onOpenChange: setOpen,
64
113
  openMobile,
65
114
  onOpenMobileChange: setOpenMobile,
66
115
  }),
67
- [open, openMobile, setOpen, setOpenMobile]
116
+ [shellId, defaultPanelWidth, open, openMobile, setOpen, setOpenMobile]
68
117
  )
69
118
 
70
119
  return {
120
+ shellId,
121
+ isNarrow,
122
+ panelWidth,
123
+ resizePanel,
71
124
  open,
72
125
  openMobile,
73
126
  setOpen,
@@ -92,6 +92,49 @@ export const FOCUSABLE_SELECTOR = [
92
92
  export const MOBILE_QUERY = "(max-width: 767px)"
93
93
  export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
94
94
 
95
+ /**
96
+ * The shell's layout contract, in px. Every entry lands on the provider's
97
+ * wrapper as a CSS variable, so a consumer overrides a column by restyling it
98
+ * rather than by passing a prop:
99
+ *
100
+ * | variable | column |
101
+ * | ---------------------- | ----------------------------------------- |
102
+ * | `--sidebar-width` | left rail, expanded |
103
+ * | `--sidebar-width-icon` | left rail, folded to icons |
104
+ * | `--panel-width` | secondary panel: its floor AND its default |
105
+ * | `--inset-min-width` | the body's floor |
106
+ *
107
+ * These are only the defaults. Every measurement resolves the live variable,
108
+ * so an override wins over the value written here.
109
+ */
110
+ export const SHELL_WIDTHS = {
111
+ "--sidebar-width": "256px",
112
+ "--sidebar-width-icon": "56px",
113
+ "--sidebar-width-mobile": "18rem",
114
+ "--panel-width": "320px",
115
+ "--inset-min-width": "360px",
116
+ } as const
117
+
118
+ /** The expanded width of a panel docked to `side`: the secondary panel opens
119
+ * at `--panel-width`, the primary rail at `--sidebar-width`. */
120
+ export function expandedWidthVar(side: "left" | "right") {
121
+ return side === "right" ? "var(--panel-width)" : "var(--sidebar-width)"
122
+ }
123
+
124
+ /** Resolve a CSS length — `var()` included — to px inside `host`'s cascade.
125
+ * Custom properties inherit, so a throwaway probe mounted in `host` reads the
126
+ * very `--sidebar-width` the shell lays out with, including a value a
127
+ * consumer overrode on the provider. Returns 0 when `host` has no layout
128
+ * (server render, `display: none`), so callers must fail open on 0 rather
129
+ * than treat it as a real measurement. */
130
+ export function resolveLength(host: HTMLElement, value: string) {
131
+ const probe = document.createElement("div")
132
+ probe.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;width:${value}`
133
+ host.appendChild(probe)
134
+ const px = probe.getBoundingClientRect().width
135
+ probe.remove()
136
+ return px
137
+ }
95
138
 
96
139
  export const AnimatedSidebarContext =
97
140
  createContext<AnimatedSidebarContextValue | null>(null)
@@ -131,13 +174,10 @@ export function useIsMobile() {
131
174
  )
132
175
  }
133
176
 
134
-
135
177
  export function useAnimatedSidebarPanel() {
136
178
  const context = useContext(AnimatedSidebarPanelContext)
137
179
  if (!context) {
138
- throw new Error(
139
- "Animated Sidebar parts must be used inside AnimatedPanel."
140
- )
180
+ throw new Error("Animated Sidebar parts must be used inside AnimatedPanel.")
141
181
  }
142
182
  return context
143
183
  }