@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,180 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { SCAFFOLD_PANEL_CAPACITY } from "./constants"
6
+ import { getPanelCapacity } from "./utils"
7
+
8
+ /**
9
+ * Internal bookkeeping for responsive panel hiding.
10
+ *
11
+ * - `visibleOrder`: visible panel ids, least-recently-activated first — the
12
+ * front is what capacity pressure evicts next.
13
+ * - `autoHidden`: panels the scaffold hid because the canvas shrank, most
14
+ * recent last. They return on their own when room comes back.
15
+ * - `userHidden`: panels hidden by a tab press. They stay hidden until the
16
+ * tab is pressed again, however wide the canvas grows.
17
+ */
18
+ interface PanelVisibilityState {
19
+ visibleOrder: string[]
20
+ autoHidden: string[]
21
+ userHidden: string[]
22
+ }
23
+
24
+ const EMPTY_STATE: PanelVisibilityState = {
25
+ visibleOrder: [],
26
+ autoHidden: [],
27
+ userHidden: [],
28
+ }
29
+
30
+ /** Fold the registered ids and current capacity into a settled state:
31
+ * prune departed panels, admit new ones, evict the least-recently-activated
32
+ * visible panels past capacity, and restore auto-hidden ones when room
33
+ * returns. Returns `prev` untouched when nothing changes. */
34
+ function reconcile(
35
+ prev: PanelVisibilityState,
36
+ ids: readonly string[],
37
+ capacity: number
38
+ ): PanelVisibilityState {
39
+ const known = new Set(ids)
40
+ const visibleOrder = prev.visibleOrder.filter((id) => known.has(id))
41
+ const autoHidden = prev.autoHidden.filter((id) => known.has(id))
42
+ const userHidden = prev.userHidden.filter((id) => known.has(id))
43
+
44
+ const tracked = new Set([...visibleOrder, ...autoHidden, ...userHidden])
45
+ for (const id of ids) if (!tracked.has(id)) visibleOrder.push(id)
46
+
47
+ while (visibleOrder.length > capacity && visibleOrder.length > 1) {
48
+ autoHidden.push(visibleOrder.shift() as string)
49
+ }
50
+ while (visibleOrder.length < capacity && autoHidden.length > 0) {
51
+ visibleOrder.push(autoHidden.pop() as string)
52
+ }
53
+
54
+ const unchanged =
55
+ visibleOrder.join(" ") === prev.visibleOrder.join(" ") &&
56
+ autoHidden.join(" ") === prev.autoHidden.join(" ") &&
57
+ userHidden.join(" ") === prev.userHidden.join(" ")
58
+ return unchanged ? prev : { visibleOrder, autoHidden, userHidden }
59
+ }
60
+
61
+ export interface PanelVisibility {
62
+ /** How many panels currently fit side by side (1..capacity cap). */
63
+ panelCapacity: number
64
+ /** Ids of panels currently hidden (auto + user), for tabs to reflect. */
65
+ hiddenPanelIds: readonly string[]
66
+ isPanelHidden: (id: string) => boolean
67
+ togglePanelVisibility: (id: string) => void
68
+ /** Id of the panel whose tab is hovered — the canvas spotlights it. */
69
+ hoveredPanelId: string | null
70
+ /** @internal Tabs report pointer enter/leave on their label. */
71
+ setPanelHovered: (id: string, hovered: boolean) => void
72
+ /** @internal Canvas reports its ordered panel ids each render. */
73
+ registerPanelIds: (ids: readonly string[]) => void
74
+ /** @internal Canvas reports its measured width. */
75
+ setCanvasWidth: (width: number) => void
76
+ }
77
+
78
+ /** Owns which panels are hidden as the canvas gains and loses room.
79
+ * Lives in `Scaffold.Root`; Canvas and Tab reach it through context. */
80
+ export function usePanelVisibility(): PanelVisibility {
81
+ const [panelIds, setPanelIds] = React.useState<readonly string[]>([])
82
+ const [measuredCapacity, setMeasuredCapacity] = React.useState<number | null>(
83
+ null
84
+ )
85
+ const [state, setState] = React.useState<PanelVisibilityState>(EMPTY_STATE)
86
+ const [hoveredPanelId, setHoveredPanelId] = React.useState<string | null>(
87
+ null
88
+ )
89
+
90
+ const panelCapacity = measuredCapacity ?? SCAFFOLD_PANEL_CAPACITY
91
+
92
+ const registerPanelIds = React.useCallback((ids: readonly string[]) => {
93
+ setPanelIds((prev) =>
94
+ prev.length === ids.length && prev.every((id, i) => id === ids[i])
95
+ ? prev
96
+ : [...ids]
97
+ )
98
+ }, [])
99
+
100
+ // Store the derived capacity, not the raw width — resize storms only
101
+ // re-render the tree when a panel actually gains or loses room.
102
+ const setCanvasWidth = React.useCallback((width: number) => {
103
+ setMeasuredCapacity(getPanelCapacity(width))
104
+ }, [])
105
+
106
+ // Settle during render (the derive-state-while-rendering pattern): ids
107
+ // and capacity arrive as state, so an effect here would only add a
108
+ // cascading render after each change.
109
+ const settled = reconcile(state, panelIds, panelCapacity)
110
+ if (settled !== state) setState(settled)
111
+
112
+ const togglePanelVisibility = React.useCallback(
113
+ (id: string) => {
114
+ setState((prev) => {
115
+ if (!panelIds.includes(id)) return prev
116
+ const hidden =
117
+ prev.autoHidden.includes(id) || prev.userHidden.includes(id)
118
+ if (!hidden && prev.visibleOrder.length <= 1) return prev
119
+
120
+ const next: PanelVisibilityState = hidden
121
+ ? {
122
+ // Showing: newest activation — reconcile evicts the oldest
123
+ // visible panel if the canvas can't fit one more.
124
+ visibleOrder: [...prev.visibleOrder, id],
125
+ autoHidden: prev.autoHidden.filter((other) => other !== id),
126
+ userHidden: prev.userHidden.filter((other) => other !== id),
127
+ }
128
+ : {
129
+ visibleOrder: prev.visibleOrder.filter((other) => other !== id),
130
+ autoHidden: prev.autoHidden,
131
+ userHidden: [...prev.userHidden, id],
132
+ }
133
+ return reconcile(next, panelIds, panelCapacity)
134
+ })
135
+ },
136
+ [panelIds, panelCapacity]
137
+ )
138
+
139
+ // Leaving clears only its own id — a tab that unmounts mid-hover must
140
+ // not wipe out a hover the pointer has already moved on to.
141
+ const setPanelHovered = React.useCallback((id: string, hovered: boolean) => {
142
+ setHoveredPanelId((prev) => {
143
+ if (hovered) return id
144
+ return prev === id ? null : prev
145
+ })
146
+ }, [])
147
+
148
+ const hiddenPanelIds = React.useMemo(
149
+ () => [...settled.autoHidden, ...settled.userHidden],
150
+ [settled]
151
+ )
152
+
153
+ const isPanelHidden = React.useCallback(
154
+ (id: string) => hiddenPanelIds.includes(id),
155
+ [hiddenPanelIds]
156
+ )
157
+
158
+ return React.useMemo(
159
+ () => ({
160
+ panelCapacity,
161
+ hiddenPanelIds,
162
+ isPanelHidden,
163
+ togglePanelVisibility,
164
+ hoveredPanelId,
165
+ setPanelHovered,
166
+ registerPanelIds,
167
+ setCanvasWidth,
168
+ }),
169
+ [
170
+ panelCapacity,
171
+ hiddenPanelIds,
172
+ isPanelHidden,
173
+ togglePanelVisibility,
174
+ hoveredPanelId,
175
+ setPanelHovered,
176
+ registerPanelIds,
177
+ setCanvasWidth,
178
+ ]
179
+ )
180
+ }
@@ -2,6 +2,25 @@ import type { Variants } from "motion/react"
2
2
  import type { ClassNameValue } from "tailwind-merge"
3
3
  import type { TSubPanelVariant } from "@/components/blocks/scaffold/type.ts"
4
4
  import { EASE_OUT } from "@/lib/ease.ts"
5
+ import {
6
+ SCAFFOLD_CANVAS_GAP,
7
+ SCAFFOLD_PANEL_CAPACITY,
8
+ SCAFFOLD_PANEL_MIN_WIDTH,
9
+ } from "./constants"
10
+
11
+ /**
12
+ * How many panels fit on a canvas of `width` px, granting each
13
+ * `SCAFFOLD_PANEL_MIN_WIDTH` plus the gaps between them. Clamped to
14
+ * [1, SCAFFOLD_PANEL_CAPACITY]; an unmeasured canvas (`null`) fits the cap.
15
+ */
16
+ export function getPanelCapacity(width: number | null): number {
17
+ if (width === null) return SCAFFOLD_PANEL_CAPACITY
18
+ const fits = Math.floor(
19
+ (width + SCAFFOLD_CANVAS_GAP) /
20
+ (SCAFFOLD_PANEL_MIN_WIDTH + SCAFFOLD_CANVAS_GAP)
21
+ )
22
+ return Math.max(1, Math.min(SCAFFOLD_PANEL_CAPACITY, fits))
23
+ }
5
24
 
6
25
  /** Desktop backdrop the whole scaffold sits on — a soft warm-gray wash. */
7
26
  export const scaffoldBackgroundClass: ClassNameValue =
@@ -0,0 +1,360 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { act, render, screen } from "@testing-library/react"
3
+ import userEvent from "@testing-library/user-event"
4
+ import { createStore, getDefaultStore } from "jotai"
5
+
6
+ import { ExegiaProvider } from "@/state"
7
+ import { ShellLayout } from "../shell-layout"
8
+ import {
9
+ measureShellFitAtom,
10
+ mountShellFitAtom,
11
+ removeShellFitInstance,
12
+ resetShellPanelWidthAtom,
13
+ resizeShellPanelAtom,
14
+ shellFitFitsAtom,
15
+ shellFitMeasuredAtom,
16
+ shellFitPanelBoundsAtom,
17
+ shellFitPanelWidthAtom,
18
+ shellFitRequestedWidthAtom,
19
+ shellFitStateAtom,
20
+ } from "../shell-fit-atom"
21
+ import type { ShellMetrics } from "../shell-metrics"
22
+ import { useShellFitActions, useShellFitState } from "../use-shell-fit-state"
23
+ import { useShellPanels } from "../use-shell-panels"
24
+ import type { TPanelMap } from "../type"
25
+ import { SHELL_WIDTHS } from "../utils"
26
+
27
+ type Store = ReturnType<typeof createStore>
28
+
29
+ /** The shell's shipping numbers on a 1024px viewport: rail 256 + body 360 +
30
+ * panel 320 = 936, with 32px of frame. */
31
+ const WIDE: ShellMetrics = {
32
+ rail: 256,
33
+ insetMin: 360,
34
+ panelMin: 320,
35
+ viewport: 1024,
36
+ chrome: 32,
37
+ }
38
+
39
+ /** The same shell squeezed to 800px — 936 needed, no room. */
40
+ const NARROW: ShellMetrics = { ...WIDE, viewport: 800 }
41
+
42
+ function measure(store: Store, id: string, metrics: ShellMetrics) {
43
+ store.set(measureShellFitAtom(id), metrics)
44
+ }
45
+
46
+ describe("shell-fit atoms · unmeasured", () => {
47
+ test("fails open: fits, no width, no measurement", () => {
48
+ const store = createStore()
49
+
50
+ expect(store.get(shellFitFitsAtom("a"))).toBe(true)
51
+ expect(store.get(shellFitMeasuredAtom("a"))).toBeNull()
52
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBeNull()
53
+ expect(store.get(shellFitPanelBoundsAtom("a"))).toEqual({ min: 0, max: 0 })
54
+ })
55
+
56
+ test("a resize before any measurement is remembered unclamped", () => {
57
+ const store = createStore()
58
+ store.set(resizeShellPanelAtom("a"), 900)
59
+
60
+ // Nothing to clamp against yet, so the ask is kept as-is …
61
+ expect(store.get(shellFitRequestedWidthAtom("a"))).toBe(900)
62
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBeNull()
63
+
64
+ // … and clamped on read the moment there is a room to clamp to.
65
+ measure(store, "a", WIDE)
66
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(1024 - 32 - 256 - 360)
67
+ })
68
+ })
69
+
70
+ describe("shell-fit atoms · the rule", () => {
71
+ test("fits while every column has its floor, not once they do not", () => {
72
+ const store = createStore()
73
+
74
+ measure(store, "a", WIDE)
75
+ expect(store.get(shellFitFitsAtom("a"))).toBe(true)
76
+
77
+ measure(store, "a", NARROW)
78
+ expect(store.get(shellFitFitsAtom("a"))).toBe(false)
79
+ })
80
+
81
+ test("the panel opens at its floor and may grow into the body's slack", () => {
82
+ const store = createStore()
83
+ measure(store, "a", WIDE)
84
+
85
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(320)
86
+ expect(store.get(shellFitPanelBoundsAtom("a"))).toEqual({
87
+ min: 320,
88
+ max: 1024 - 32 - 256 - 360,
89
+ })
90
+ })
91
+
92
+ test("a resize is clamped on the way in", () => {
93
+ const store = createStore()
94
+ measure(store, "a", WIDE)
95
+
96
+ store.set(resizeShellPanelAtom("a"), 900)
97
+ expect(store.get(shellFitRequestedWidthAtom("a"))).toBe(376)
98
+
99
+ store.set(resizeShellPanelAtom("a"), 10)
100
+ expect(store.get(shellFitRequestedWidthAtom("a"))).toBe(320)
101
+ })
102
+
103
+ test("a narrowing viewport pulls the panel down without forgetting the ask", () => {
104
+ const store = createStore()
105
+ measure(store, "a", WIDE)
106
+ store.set(resizeShellPanelAtom("a"), 360)
107
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(360)
108
+
109
+ // Less slack: 1000 - 32 - 256 - 360 = 352.
110
+ measure(store, "a", { ...WIDE, viewport: 1000 })
111
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(352)
112
+ expect(store.get(shellFitRequestedWidthAtom("a"))).toBe(360)
113
+
114
+ // Room comes back, and so does the width they chose.
115
+ measure(store, "a", WIDE)
116
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(360)
117
+ })
118
+
119
+ test("folding the rail hands its column to the panel's ceiling", () => {
120
+ const store = createStore()
121
+ measure(store, "a", WIDE)
122
+ expect(store.get(shellFitPanelBoundsAtom("a")).max).toBe(376)
123
+
124
+ measure(store, "a", { ...WIDE, rail: 56 })
125
+ expect(store.get(shellFitPanelBoundsAtom("a")).max).toBe(576)
126
+ })
127
+
128
+ test("a measurement that changes nothing is not a change", () => {
129
+ const store = createStore()
130
+ measure(store, "a", WIDE)
131
+ const before = store.get(shellFitStateAtom("a"))
132
+
133
+ measure(store, "a", { ...WIDE })
134
+ expect(store.get(shellFitStateAtom("a"))).toBe(before)
135
+ })
136
+ })
137
+
138
+ describe("shell-fit atoms · seed and reset", () => {
139
+ test("mount seeds the requested width once", () => {
140
+ const store = createStore()
141
+ store.set(mountShellFitAtom("a"), { panelWidth: 400 })
142
+ measure(store, "a", { ...WIDE, viewport: 1400 })
143
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(400)
144
+
145
+ // A later mount (StrictMode, a re-render with a new default) never
146
+ // overwrites a width the user dragged to.
147
+ store.set(resizeShellPanelAtom("a"), 500)
148
+ store.set(mountShellFitAtom("a"), { panelWidth: 340 })
149
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(500)
150
+ })
151
+
152
+ test("reset goes back to the seed, or the floor without one", () => {
153
+ const store = createStore()
154
+ store.set(mountShellFitAtom("a"), { panelWidth: 400 })
155
+ measure(store, "a", { ...WIDE, viewport: 1400 })
156
+ store.set(resizeShellPanelAtom("a"), 500)
157
+
158
+ store.set(resetShellPanelWidthAtom("a"))
159
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(400)
160
+
161
+ measure(store, "b", WIDE)
162
+ store.set(resizeShellPanelAtom("b"), 360)
163
+ store.set(resetShellPanelWidthAtom("b"))
164
+ expect(store.get(shellFitPanelWidthAtom("b"))).toBe(320)
165
+ })
166
+ })
167
+
168
+ describe("shell-fit atoms · instances", () => {
169
+ test("two ids never share state", () => {
170
+ const store = createStore()
171
+ measure(store, "a", WIDE)
172
+ measure(store, "b", NARROW)
173
+
174
+ expect(store.get(shellFitFitsAtom("a"))).toBe(true)
175
+ expect(store.get(shellFitFitsAtom("b"))).toBe(false)
176
+ })
177
+
178
+ test("removing an instance drops its state", () => {
179
+ const store = createStore()
180
+ measure(store, "a", WIDE)
181
+ store.set(resizeShellPanelAtom("a"), 360)
182
+
183
+ removeShellFitInstance("a")
184
+
185
+ expect(store.get(shellFitMeasuredAtom("a"))).toBeNull()
186
+ expect(store.get(shellFitRequestedWidthAtom("a"))).toBeNull()
187
+ })
188
+
189
+ test("two stores never share state", () => {
190
+ const one = createStore()
191
+ const two = createStore()
192
+ measure(one, "a", NARROW)
193
+
194
+ expect(one.get(shellFitFitsAtom("a"))).toBe(false)
195
+ expect(two.get(shellFitFitsAtom("a"))).toBe(true)
196
+ })
197
+ })
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // By id, through React
201
+ // ---------------------------------------------------------------------------
202
+
203
+ const PANELS: TPanelMap = {
204
+ left: {
205
+ id: "nav",
206
+ name: "Navigation",
207
+ component: <div>Nav body</div>,
208
+ open: true,
209
+ side: "left",
210
+ },
211
+ right: {
212
+ id: "inspector",
213
+ name: "Inspector",
214
+ component: <div>Inspector body</div>,
215
+ open: true,
216
+ side: "right",
217
+ },
218
+ }
219
+
220
+ /** Answer the shell's CSS-variable probes with real px (happy-dom has no
221
+ * layout engine); see `shell-layout.test.tsx` for the full story. */
222
+ function stubShellWidths() {
223
+ const px = Object.fromEntries(
224
+ Object.entries(SHELL_WIDTHS).map(([name, value]) => [
225
+ name,
226
+ Number.parseFloat(value),
227
+ ])
228
+ )
229
+ const original = HTMLElement.prototype.getBoundingClientRect
230
+ HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
231
+ const isProbe =
232
+ this.style.visibility === "hidden" && this.style.position === "absolute"
233
+ if (!isProbe) return original.call(this)
234
+ const variable = /var\((--[a-z-]+)\)/.exec(this.style.width)?.[1]
235
+ return { width: (variable && px[variable]) || 0 } as DOMRect
236
+ }
237
+ return () => {
238
+ HTMLElement.prototype.getBoundingClientRect = original
239
+ }
240
+ }
241
+
242
+ /** Reads and drives the shell by id — mounted as a SIBLING of the shell, so
243
+ * it holds no controller and sits under no shell context. */
244
+ function RemoteInspectorControls({ shellId }: { shellId: string }) {
245
+ const { fits, panelWidth, bounds } = useShellFitState(shellId)
246
+ const { resizePanel, resetPanelWidth } = useShellFitActions(shellId)
247
+ return (
248
+ <div>
249
+ <p>{`fits: ${String(fits)}`}</p>
250
+ <p>{`width: ${String(panelWidth)}`}</p>
251
+ <p>{`max: ${bounds.max}`}</p>
252
+ <button onClick={() => resizePanel(360)} type="button">
253
+ Widen
254
+ </button>
255
+ <button onClick={resetPanelWidth} type="button">
256
+ Reset width
257
+ </button>
258
+ </div>
259
+ )
260
+ }
261
+
262
+ function NamedShellUnprovided({ shellId }: { shellId?: string }) {
263
+ const panels = useShellPanels({ shellId })
264
+ return <ShellLayout {...panels.providerProps} panels={PANELS} variant="web" />
265
+ }
266
+
267
+ /** `useShellPanels` reads the same atoms the shell writes, so it has to sit
268
+ * under the same `ExegiaProvider` as the shell — mounted above it, it would
269
+ * read Jotai's default store while the shell writes to the provider's. */
270
+ function NamedShell({ shellId }: { shellId?: string }) {
271
+ const panels = useShellPanels({ shellId, defaultPanelWidth: 340 })
272
+ return (
273
+ <>
274
+ <RemoteInspectorControls shellId={panels.shellId} />
275
+ <p>{`hook width: ${String(panels.panelWidth)}`}</p>
276
+ <ShellLayout {...panels.providerProps} panels={PANELS} variant="web" />
277
+ </>
278
+ )
279
+ }
280
+
281
+ describe("shell-fit · by id", () => {
282
+ test("a named shell is readable and resizable from outside it", async () => {
283
+ const user = userEvent.setup()
284
+ const restore = stubShellWidths()
285
+ try {
286
+ // happy-dom's default 1024px viewport: 936 needed. No stylesheet, so the
287
+ // frame measures 0 and the ceiling is 1024 - 256 - 360 = 408.
288
+ render(
289
+ <ExegiaProvider>
290
+ <NamedShell shellId="app-shell" />
291
+ </ExegiaProvider>
292
+ )
293
+
294
+ expect(screen.getByText("fits: true")).toBeDefined()
295
+ expect(screen.getByText("max: 408")).toBeDefined()
296
+ // `defaultPanelWidth` seeded the width, and the hook above the provider
297
+ // reads it back out of the same atoms.
298
+ expect(screen.getByText("width: 340")).toBeDefined()
299
+ expect(screen.getByText("hook width: 340")).toBeDefined()
300
+
301
+ await user.click(screen.getByRole("button", { name: "Widen" }))
302
+ expect(screen.getByText("width: 360")).toBeDefined()
303
+ expect(screen.getByText("hook width: 360")).toBeDefined()
304
+
305
+ await user.click(screen.getByRole("button", { name: "Reset width" }))
306
+ expect(screen.getByText("width: 340")).toBeDefined()
307
+ } finally {
308
+ restore()
309
+ removeShellFitInstance("app-shell")
310
+ }
311
+ })
312
+
313
+ test("a generated id is dropped with the hook that made it", () => {
314
+ const restore = stubShellWidths()
315
+ function Probe() {
316
+ const panels = useShellPanels()
317
+ return (
318
+ <ShellLayout {...panels.providerProps} panels={PANELS} variant="web">
319
+ <span data-testid="shell-id">{panels.shellId}</span>
320
+ </ShellLayout>
321
+ )
322
+ }
323
+ try {
324
+ const { unmount } = render(<Probe />)
325
+ const shellId = screen.getByTestId("shell-id").textContent ?? ""
326
+ // No ExegiaProvider here, so the hooks fall back to Jotai's default
327
+ // store — the provider wrote its measurement there under the hook's id.
328
+ const store = getDefaultStore()
329
+ expect(shellId).not.toBe("")
330
+ expect(store.get(shellFitMeasuredAtom(shellId))).not.toBeNull()
331
+
332
+ unmount()
333
+ // Nothing is left under it once the hook is gone: the family hands out
334
+ // a fresh atom, and a fresh atom reads as unmeasured.
335
+ expect(store.get(shellFitMeasuredAtom(shellId))).toBeNull()
336
+ } finally {
337
+ restore()
338
+ }
339
+ })
340
+
341
+ test("an explicit id outlives the shell", () => {
342
+ const restore = stubShellWidths()
343
+ try {
344
+ const store = getDefaultStore()
345
+ const { unmount } = render(<NamedShellUnprovided shellId="kept" />)
346
+ act(() => {
347
+ store.set(resizeShellPanelAtom("kept"), 360)
348
+ })
349
+ expect(store.get(shellFitRequestedWidthAtom("kept"))).toBe(360)
350
+
351
+ unmount()
352
+ // The dragged width is still there for the next mount — a route change
353
+ // does not reset the inspector.
354
+ expect(store.get(shellFitRequestedWidthAtom("kept"))).toBe(360)
355
+ } finally {
356
+ restore()
357
+ removeShellFitInstance("kept")
358
+ }
359
+ })
360
+ })