@exegia/corpora-ui 0.22.0 → 0.23.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 (40) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/auth-flow-block.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/auth-flow-block.d.ts +123 -0
  3. package/dist-lib/components/blocks/scaffold/__tests__/scaffold-atom.test.d.ts +1 -0
  4. package/dist-lib/components/blocks/scaffold/index.d.ts +2 -0
  5. package/dist-lib/components/blocks/scaffold/scaffold-atom.d.ts +97 -0
  6. package/dist-lib/components/blocks/scaffold/scaffold-context.d.ts +3 -1
  7. package/dist-lib/components/blocks/scaffold/scaffold-root.d.ts +12 -3
  8. package/dist-lib/components/blocks/scaffold/type.d.ts +58 -18
  9. package/dist-lib/components/blocks/scaffold/use-scaffold-state.d.ts +26 -0
  10. package/dist-lib/components/blocks/scaffold/use-scaffold.d.ts +7 -1
  11. package/dist-lib/components/blocks/scaffold/utils.d.ts +11 -1
  12. package/dist-lib/components/ui/switch.d.ts +4 -0
  13. package/dist-lib/index.d.ts +1 -0
  14. package/dist-lib/index.js +1383 -1196
  15. package/dist-lib/index.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/components/beste/piece/browser-frame.tsx +1 -1
  18. package/src/components/beste/piece/utils.ts +4 -4
  19. package/src/components/blocks/auth/__tests__/auth-flow-block.test.tsx +183 -0
  20. package/src/components/blocks/auth/auth-flow-block.tsx +296 -0
  21. package/src/components/blocks/scaffold/__tests__/scaffold-atom.test.tsx +210 -0
  22. package/src/components/blocks/scaffold/index.ts +15 -0
  23. package/src/components/blocks/scaffold/scaffold-actions.tsx +5 -1
  24. package/src/components/blocks/scaffold/scaffold-atom.ts +383 -0
  25. package/src/components/blocks/scaffold/scaffold-canvas.tsx +12 -4
  26. package/src/components/blocks/scaffold/scaffold-context.ts +3 -1
  27. package/src/components/blocks/scaffold/scaffold-inspector.tsx +9 -2
  28. package/src/components/blocks/scaffold/scaffold-panel.tsx +9 -5
  29. package/src/components/blocks/scaffold/scaffold-root.tsx +64 -32
  30. package/src/components/blocks/scaffold/scaffold-tab.tsx +13 -9
  31. package/src/components/blocks/scaffold/type.ts +64 -18
  32. package/src/components/blocks/scaffold/use-scaffold-state.ts +58 -0
  33. package/src/components/blocks/scaffold/use-scaffold.ts +30 -19
  34. package/src/components/blocks/scaffold/utils.ts +45 -1
  35. package/src/components/docs/component-preview.tsx +7 -2
  36. package/src/components/docs/demo-controls.tsx +32 -23
  37. package/src/components/ui/switch.tsx +30 -0
  38. package/src/index.ts +1 -0
  39. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +0 -19
  40. package/src/components/blocks/scaffold/use-panel-visibility.ts +0 -180
@@ -0,0 +1,210 @@
1
+ import { describe, expect, mock, test } from "bun:test"
2
+ import { createStore } from "jotai"
3
+
4
+ import {
5
+ measureScaffoldCanvasAtom,
6
+ projectScaffoldPropsAtom,
7
+ registerScaffoldPanelIdsAtom,
8
+ removeScaffoldInstance,
9
+ resetScaffoldAtom,
10
+ scaffoldHiddenPanelIdsAtom,
11
+ scaffoldHoveredPanelIdAtom,
12
+ scaffoldInspectorOpenAtom,
13
+ scaffoldPanelCapacityAtom,
14
+ scaffoldPanelDimmedAtom,
15
+ scaffoldPanelHiddenAtom,
16
+ scaffoldStateAtom,
17
+ scaffoldVisibilityAtom,
18
+ setScaffoldHandlersAtom,
19
+ setScaffoldInspectorOpenAtom,
20
+ setScaffoldPanelHoveredAtom,
21
+ toggleScaffoldInspectorAtom,
22
+ toggleScaffoldPanelAtom,
23
+ } from "../scaffold-atom"
24
+
25
+ type Store = ReturnType<typeof createStore>
26
+
27
+ let keys = 0
28
+ /** A fresh instance per test — ids never collide, no cross-test state. */
29
+ const instance = () => `scaffold-test-${keys++}`
30
+
31
+ /** Register three panels on a canvas wide enough for all of them. */
32
+ function mountThree(store: Store, id: string) {
33
+ store.set(registerScaffoldPanelIdsAtom(id), ["one", "two", "three"])
34
+ store.set(measureScaffoldCanvasAtom(id), 1200)
35
+ }
36
+
37
+ describe("scaffold panel visibility atoms", () => {
38
+ test("narrowing evicts the least-recently-activated panel; widening restores it", () => {
39
+ const store = createStore()
40
+ const id = instance()
41
+ mountThree(store, id)
42
+ expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual([])
43
+
44
+ // Two panels' worth of room.
45
+ store.set(measureScaffoldCanvasAtom(id), 700)
46
+ expect(store.get(scaffoldPanelCapacityAtom(id))).toBe(2)
47
+ expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual(["one"])
48
+ expect(store.get(scaffoldPanelHiddenAtom(id, "one"))).toBe(true)
49
+ expect(store.get(scaffoldPanelHiddenAtom(id, "two"))).toBe(false)
50
+
51
+ // Room returns — the auto-hidden panel comes back by itself.
52
+ store.set(measureScaffoldCanvasAtom(id), 1200)
53
+ expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual([])
54
+ })
55
+
56
+ test("showing a hidden panel past capacity trades places with the oldest visible one", () => {
57
+ const store = createStore()
58
+ const id = instance()
59
+ mountThree(store, id)
60
+ store.set(measureScaffoldCanvasAtom(id), 700)
61
+ expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual(["one"])
62
+
63
+ store.set(toggleScaffoldPanelAtom(id), "one")
64
+ expect(store.get(scaffoldPanelHiddenAtom(id, "one"))).toBe(false)
65
+ expect(store.get(scaffoldPanelHiddenAtom(id, "two"))).toBe(true)
66
+ })
67
+
68
+ test("user-hidden panels stay hidden when the canvas widens; a press shows them again", () => {
69
+ const store = createStore()
70
+ const id = instance()
71
+ mountThree(store, id)
72
+
73
+ store.set(toggleScaffoldPanelAtom(id), "two")
74
+ expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual(["two"])
75
+
76
+ store.set(measureScaffoldCanvasAtom(id), 1400)
77
+ expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual(["two"])
78
+
79
+ store.set(toggleScaffoldPanelAtom(id), "two")
80
+ expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual([])
81
+ })
82
+
83
+ test("the last visible panel refuses to hide; unknown ids are inert", () => {
84
+ const store = createStore()
85
+ const id = instance()
86
+ mountThree(store, id)
87
+ store.set(measureScaffoldCanvasAtom(id), 400)
88
+ expect(store.get(scaffoldPanelCapacityAtom(id))).toBe(1)
89
+ expect(store.get(scaffoldPanelHiddenAtom(id, "three"))).toBe(false)
90
+
91
+ store.set(toggleScaffoldPanelAtom(id), "three")
92
+ expect(store.get(scaffoldPanelHiddenAtom(id, "three"))).toBe(false)
93
+
94
+ store.set(toggleScaffoldPanelAtom(id), "nonexistent")
95
+ expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual(["one", "two"])
96
+ })
97
+
98
+ test("departed panels are pruned from every list on re-registration", () => {
99
+ const store = createStore()
100
+ const id = instance()
101
+ mountThree(store, id)
102
+ store.set(toggleScaffoldPanelAtom(id), "two")
103
+
104
+ store.set(registerScaffoldPanelIdsAtom(id), ["one", "three"])
105
+ expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual([])
106
+ })
107
+
108
+ test("a resize that lands on the same capacity leaves the visibility untouched", () => {
109
+ const store = createStore()
110
+ const id = instance()
111
+ mountThree(store, id)
112
+ const before = store.get(scaffoldVisibilityAtom(id))
113
+
114
+ // 1200 → 1000 both fit three panels.
115
+ store.set(measureScaffoldCanvasAtom(id), 1000)
116
+ expect(store.get(scaffoldVisibilityAtom(id))).toBe(before)
117
+ })
118
+ })
119
+
120
+ describe("scaffold hover atoms", () => {
121
+ test("hover spotlights one panel; a stale leave never clears a newer hover", () => {
122
+ const store = createStore()
123
+ const id = instance()
124
+ mountThree(store, id)
125
+
126
+ store.set(setScaffoldPanelHoveredAtom(id), "one", true)
127
+ expect(store.get(scaffoldPanelDimmedAtom(id, "one"))).toBe(false)
128
+ expect(store.get(scaffoldPanelDimmedAtom(id, "two"))).toBe(true)
129
+
130
+ // The pointer moved on before "one"'s leave fired.
131
+ store.set(setScaffoldPanelHoveredAtom(id), "two", true)
132
+ store.set(setScaffoldPanelHoveredAtom(id), "one", false)
133
+ expect(store.get(scaffoldHoveredPanelIdAtom(id))).toBe("two")
134
+
135
+ store.set(setScaffoldPanelHoveredAtom(id), "two", false)
136
+ expect(store.get(scaffoldHoveredPanelIdAtom(id))).toBe(null)
137
+ expect(store.get(scaffoldPanelDimmedAtom(id, "one"))).toBe(false)
138
+ })
139
+
140
+ test("the empty-key sentinel reads false however the scaffold moves", () => {
141
+ const store = createStore()
142
+ const id = instance()
143
+ store.set(setScaffoldPanelHoveredAtom(id), "one", true)
144
+ expect(store.get(scaffoldPanelDimmedAtom(id, ""))).toBe(false)
145
+ expect(store.get(scaffoldPanelHiddenAtom(id, ""))).toBe(false)
146
+ })
147
+ })
148
+
149
+ describe("scaffold inspector atoms", () => {
150
+ test("set and toggle report through the handler and write the store when uncontrolled", () => {
151
+ const store = createStore()
152
+ const id = instance()
153
+ const onInspectorOpenChange = mock(() => {})
154
+ store.set(setScaffoldHandlersAtom(id), { onInspectorOpenChange })
155
+
156
+ store.set(setScaffoldInspectorOpenAtom(id), true)
157
+ expect(store.get(scaffoldInspectorOpenAtom(id))).toBe(true)
158
+ expect(onInspectorOpenChange).toHaveBeenCalledWith(true)
159
+
160
+ // Setting the value it already holds is silent.
161
+ store.set(setScaffoldInspectorOpenAtom(id), true)
162
+ expect(onInspectorOpenChange).toHaveBeenCalledTimes(1)
163
+
164
+ store.set(toggleScaffoldInspectorAtom(id))
165
+ expect(store.get(scaffoldInspectorOpenAtom(id))).toBe(false)
166
+ expect(onInspectorOpenChange).toHaveBeenLastCalledWith(false)
167
+ })
168
+
169
+ test("a controlled inspector gates store writes; actions only report", () => {
170
+ const store = createStore()
171
+ const id = instance()
172
+ const onInspectorOpenChange = mock(() => {})
173
+ store.set(setScaffoldHandlersAtom(id), { onInspectorOpenChange })
174
+ store.set(projectScaffoldPropsAtom(id), { controlsInspector: true }, false)
175
+
176
+ store.set(setScaffoldInspectorOpenAtom(id), true)
177
+ expect(store.get(scaffoldInspectorOpenAtom(id))).toBe(false)
178
+ expect(onInspectorOpenChange).toHaveBeenCalledWith(true)
179
+
180
+ // The prop coming back around through the projection is what moves it.
181
+ store.set(projectScaffoldPropsAtom(id), { controlsInspector: true }, true)
182
+ expect(store.get(scaffoldInspectorOpenAtom(id))).toBe(true)
183
+ })
184
+ })
185
+
186
+ describe("scaffold instance lifecycle", () => {
187
+ test("reset restores every field; remove drops the id from every family", () => {
188
+ const store = createStore()
189
+ const id = instance()
190
+ mountThree(store, id)
191
+ store.set(measureScaffoldCanvasAtom(id), 700)
192
+ store.set(setScaffoldInspectorOpenAtom(id), true)
193
+ store.set(setScaffoldPanelHoveredAtom(id), "two", true)
194
+
195
+ store.set(resetScaffoldAtom(id))
196
+ expect(store.get(scaffoldStateAtom(id))).toEqual({
197
+ inspectorOpen: false,
198
+ panelCapacity: 3,
199
+ hiddenPanelIds: [],
200
+ hoveredPanelId: null,
201
+ })
202
+
203
+ // A removed id reads fresh atoms — state from before is gone.
204
+ mountThree(store, id)
205
+ store.set(measureScaffoldCanvasAtom(id), 700)
206
+ removeScaffoldInstance(id)
207
+ expect(store.get(scaffoldPanelCapacityAtom(id))).toBe(3)
208
+ expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual([])
209
+ })
210
+ })
@@ -12,6 +12,21 @@ import { ScaffoldSubPanel } from "@/components/blocks/scaffold/scaffold-sub-pane
12
12
 
13
13
  export { useScaffoldContext } from "./scaffold-context"
14
14
  export { useScaffold } from "./use-scaffold"
15
+ export { useScaffoldActions, useScaffoldState } from "./use-scaffold-state"
16
+ export {
17
+ removeScaffoldInstance,
18
+ resetScaffoldAtom,
19
+ scaffoldHiddenPanelIdsAtom,
20
+ scaffoldHoveredPanelIdAtom,
21
+ scaffoldInspectorOpenAtom,
22
+ scaffoldPanelCapacityAtom,
23
+ scaffoldPanelDimmedAtom,
24
+ scaffoldPanelHiddenAtom,
25
+ scaffoldStateAtom,
26
+ setScaffoldInspectorOpenAtom,
27
+ toggleScaffoldInspectorAtom,
28
+ toggleScaffoldPanelAtom,
29
+ } from "./scaffold-atom"
15
30
  export type * from "./type"
16
31
  export * from "./constants"
17
32
 
@@ -3,8 +3,11 @@
3
3
  import { AnimatePresence, motion, useReducedMotion } from "motion/react"
4
4
  import * as React from "react"
5
5
 
6
+ import { useAtomValue } from "jotai"
7
+
6
8
  import { cn } from "@/lib/utils"
7
9
  import { SCAFFOLD_EASE, SCAFFOLD_EDGE_GUTTER, SCAFFOLD_MORPH_DURATION } from "./constants"
10
+ import { scaffoldInspectorOpenAtom } from "./scaffold-atom"
8
11
  import { useScaffoldContext } from "./scaffold-context"
9
12
  import type { ScaffoldActionsProps } from "./type"
10
13
  import { segmentVariants } from "./utils"
@@ -28,7 +31,8 @@ export function ScaffoldActions({
28
31
  children,
29
32
  ...rest
30
33
  }: ScaffoldActionsProps): React.ReactElement {
31
- const { inspectorOpen, inspectorWidth } = useScaffoldContext()
34
+ const { scaffoldId, inspectorWidth } = useScaffoldContext()
35
+ const inspectorOpen = useAtomValue(scaffoldInspectorOpenAtom(scaffoldId))
32
36
  const reducedMotion = useReducedMotion()
33
37
  const transition = reducedMotion ? { duration: 0 } : SPRING_LAYOUT
34
38
 
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Scaffold state — Jotai atom families keyed by a scaffold id.
3
+ *
4
+ * Same layout as the tree (`components/composed/tree/tree-atom.ts`), the
5
+ * shell (`blocks/shell/shell-fit-atom.ts`) and the profile card: in-house
6
+ * `keyed()` families per field, write-only action atoms, a composed
7
+ * read-only state atom, and `removeScaffoldInstance` to drop an id from
8
+ * every family.
9
+ *
10
+ * The visibility bookkeeping (`visibleOrder` / `autoHidden` / `userHidden`)
11
+ * stays one atom on purpose — `reconcileVisibility` always settles the three
12
+ * lists together, so splitting them would only invite half-written states.
13
+ * The reconcile rule itself lives in `utils.ts`, and every action that moves
14
+ * an input (ids, capacity, a toggle) settles the visibility before it
15
+ * returns, so readers never see an interim state — the render-time settling
16
+ * the old `usePanelVisibility` hook needed has no equivalent here.
17
+ */
18
+
19
+ import { atom } from "jotai"
20
+ import type { Atom, Getter, Setter } from "jotai"
21
+
22
+ import { SCAFFOLD_PANEL_CAPACITY } from "./constants"
23
+ import type {
24
+ ScaffoldConfig,
25
+ ScaffoldHandlers,
26
+ ScaffoldInstanceId,
27
+ ScaffoldPanelVisibility,
28
+ ScaffoldState,
29
+ } from "./type"
30
+ import {
31
+ EMPTY_SCAFFOLD_VISIBILITY,
32
+ getPanelCapacity,
33
+ reconcileVisibility,
34
+ } from "./utils"
35
+
36
+ const DEFAULT_CONFIG: ScaffoldConfig = { controlsInspector: false }
37
+ const NO_HANDLERS: ScaffoldHandlers = {}
38
+ const NO_PANEL_IDS: readonly string[] = []
39
+
40
+ // ── families ───────────────────────────────────────────────────────────────
41
+
42
+ type Family<AtomType> = ((id: ScaffoldInstanceId) => AtomType) & {
43
+ remove: (id: ScaffoldInstanceId) => void
44
+ }
45
+
46
+ const families: { remove: (id: ScaffoldInstanceId) => void }[] = []
47
+
48
+ function keyed<AtomType>(create: (id: ScaffoldInstanceId) => AtomType) {
49
+ const cache = new Map<ScaffoldInstanceId, AtomType>()
50
+ const family = ((id: ScaffoldInstanceId) => {
51
+ let instance = cache.get(id)
52
+ if (instance === undefined) {
53
+ instance = create(id)
54
+ cache.set(id, instance)
55
+ }
56
+ return instance
57
+ }) as Family<AtomType>
58
+ family.remove = (id: ScaffoldInstanceId) => {
59
+ cache.delete(id)
60
+ }
61
+ families.push(family)
62
+ return family
63
+ }
64
+
65
+ function stateFamily<Value>(name: string, initialValue: Value) {
66
+ return keyed((id) => {
67
+ const instance = atom(initialValue)
68
+ instance.debugLabel = `scaffold/${id}/${name}`
69
+ return instance
70
+ })
71
+ }
72
+
73
+ function readFamily<Value>(
74
+ name: string,
75
+ read: (get: Getter, id: ScaffoldInstanceId) => Value
76
+ ) {
77
+ return keyed((id) => {
78
+ const instance = atom((get) => read(get, id))
79
+ instance.debugLabel = `scaffold/${id}/${name}`
80
+ return instance
81
+ })
82
+ }
83
+
84
+ function actionFamily<Args extends unknown[]>(
85
+ name: string,
86
+ write: (
87
+ get: Getter,
88
+ set: Setter,
89
+ id: ScaffoldInstanceId,
90
+ ...args: Args
91
+ ) => void
92
+ ) {
93
+ return keyed((id) => {
94
+ const instance = atom(null, (get, set, ...args: Args) =>
95
+ write(get, set, id, ...args)
96
+ )
97
+ instance.debugLabel = `scaffold/${id}/${name}`
98
+ return instance
99
+ })
100
+ }
101
+
102
+ /** A family keyed by scaffold AND panel, so a tab or a panel subscribes to
103
+ * its own boolean and sits still when a sibling's changes — the per-node
104
+ * rule from the tree, at panel scale. Removal drops a whole scaffold. */
105
+ function panelFamily<Value>(
106
+ name: string,
107
+ read: (get: Getter, id: ScaffoldInstanceId, panelId: string) => Value
108
+ ) {
109
+ const cache = new Map<ScaffoldInstanceId, Map<string, Atom<Value>>>()
110
+ const family = (id: ScaffoldInstanceId, panelId: string): Atom<Value> => {
111
+ let panels = cache.get(id)
112
+ if (panels === undefined) {
113
+ panels = new Map()
114
+ cache.set(id, panels)
115
+ }
116
+ let instance = panels.get(panelId)
117
+ if (instance === undefined) {
118
+ const created = atom((get) => read(get, id, panelId))
119
+ created.debugLabel = `scaffold/${id}/${panelId}/${name}`
120
+ panels.set(panelId, created)
121
+ instance = created
122
+ }
123
+ return instance
124
+ }
125
+ families.push({
126
+ remove: (id: ScaffoldInstanceId) => {
127
+ cache.delete(id)
128
+ },
129
+ })
130
+ return family
131
+ }
132
+
133
+ // ── primitives ─────────────────────────────────────────────────────────────
134
+
135
+ export const scaffoldInspectorOpenAtom = stateFamily<boolean>(
136
+ "inspectorOpen",
137
+ false
138
+ )
139
+
140
+ /** @internal The canvas's ordered panel ids, as registered each render. */
141
+ export const scaffoldPanelIdsAtom = stateFamily<readonly string[]>(
142
+ "panelIds",
143
+ NO_PANEL_IDS
144
+ )
145
+
146
+ /** @internal The capacity derived from the canvas's measured width — the
147
+ * derived number is stored, not the raw width, so resize storms only touch
148
+ * the store when a panel actually gains or loses room. */
149
+ export const scaffoldMeasuredCapacityAtom = stateFamily<number | null>(
150
+ "measuredCapacity",
151
+ null
152
+ )
153
+
154
+ /** @internal The three visibility lists, settled as one value. */
155
+ export const scaffoldVisibilityAtom = stateFamily<ScaffoldPanelVisibility>(
156
+ "visibility",
157
+ EMPTY_SCAFFOLD_VISIBILITY
158
+ )
159
+
160
+ export const scaffoldHoveredPanelIdAtom = stateFamily<string | null>(
161
+ "hoveredPanelId",
162
+ null
163
+ )
164
+
165
+ /** @internal */
166
+ export const scaffoldConfigAtom = stateFamily<ScaffoldConfig>(
167
+ "config",
168
+ DEFAULT_CONFIG
169
+ )
170
+
171
+ /** @internal */
172
+ export const scaffoldHandlersAtom = stateFamily<ScaffoldHandlers>(
173
+ "handlers",
174
+ NO_HANDLERS
175
+ )
176
+
177
+ // ── derived ────────────────────────────────────────────────────────────────
178
+
179
+ /** How many panels fit side by side; the cap while the canvas is unmeasured. */
180
+ export const scaffoldPanelCapacityAtom = readFamily(
181
+ "panelCapacity",
182
+ (get, id) =>
183
+ get(scaffoldMeasuredCapacityAtom(id)) ?? SCAFFOLD_PANEL_CAPACITY
184
+ )
185
+
186
+ /** Ids of panels currently hidden (auto + user), for tabs to reflect. */
187
+ export const scaffoldHiddenPanelIdsAtom = readFamily<readonly string[]>(
188
+ "hiddenPanelIds",
189
+ (get, id) => {
190
+ const visibility = get(scaffoldVisibilityAtom(id))
191
+ return [...visibility.autoHidden, ...visibility.userHidden]
192
+ }
193
+ )
194
+
195
+ /** Whether one panel is hidden. A tab subscribes here and sits still while
196
+ * its siblings toggle. Empty `panelId` (a tab without one) reads false. */
197
+ export const scaffoldPanelHiddenAtom = panelFamily(
198
+ "hidden",
199
+ (get, id, panelId) => {
200
+ if (panelId === "") return false
201
+ const visibility = get(scaffoldVisibilityAtom(id))
202
+ return (
203
+ visibility.autoHidden.includes(panelId) ||
204
+ visibility.userHidden.includes(panelId)
205
+ )
206
+ }
207
+ )
208
+
209
+ /** Whether one panel fades back while another panel's tab holds the
210
+ * spotlight. Moving the hover between two tabs re-renders those panels
211
+ * only — everyone else's boolean holds. */
212
+ export const scaffoldPanelDimmedAtom = panelFamily(
213
+ "dimmed",
214
+ (get, id, panelId) => {
215
+ if (panelId === "") return false
216
+ const hovered = get(scaffoldHoveredPanelIdAtom(id))
217
+ return hovered !== null && hovered !== panelId
218
+ }
219
+ )
220
+
221
+ /** The whole state of one scaffold. This changes on every hover move, so a
222
+ * component that reads one field should subscribe to that field's atom
223
+ * instead: `useAtomValue(scaffoldInspectorOpenAtom("workspace"))`. */
224
+ export const scaffoldStateAtom = readFamily<ScaffoldState>(
225
+ "state",
226
+ (get, id) => ({
227
+ inspectorOpen: get(scaffoldInspectorOpenAtom(id)),
228
+ panelCapacity: get(scaffoldPanelCapacityAtom(id)),
229
+ hiddenPanelIds: get(scaffoldHiddenPanelIdsAtom(id)),
230
+ hoveredPanelId: get(scaffoldHoveredPanelIdAtom(id)),
231
+ })
232
+ )
233
+
234
+ // ── visibility settling ────────────────────────────────────────────────────
235
+
236
+ /** Re-settle the lists after any input moved. Inert when nothing changes —
237
+ * `reconcileVisibility` returns `prev` untouched then. */
238
+ function settleVisibility(get: Getter, set: Setter, id: ScaffoldInstanceId) {
239
+ const prev = get(scaffoldVisibilityAtom(id))
240
+ const settled = reconcileVisibility(
241
+ prev,
242
+ get(scaffoldPanelIdsAtom(id)),
243
+ get(scaffoldPanelCapacityAtom(id))
244
+ )
245
+ if (settled !== prev) set(scaffoldVisibilityAtom(id), settled)
246
+ }
247
+
248
+ // ── actions: inspector ─────────────────────────────────────────────────────
249
+
250
+ /** Reports through `onInspectorOpenChange` always; writes the store only
251
+ * when no `inspectorOpen` prop controls the root — the prop is truth then. */
252
+ export const setScaffoldInspectorOpenAtom = actionFamily<[open: boolean]>(
253
+ "setInspectorOpen",
254
+ (get, set, id, open) => {
255
+ if (get(scaffoldInspectorOpenAtom(id)) === open) return
256
+ if (!get(scaffoldConfigAtom(id)).controlsInspector)
257
+ set(scaffoldInspectorOpenAtom(id), open)
258
+ get(scaffoldHandlersAtom(id)).onInspectorOpenChange?.(open)
259
+ }
260
+ )
261
+
262
+ export const toggleScaffoldInspectorAtom = actionFamily<[]>(
263
+ "toggleInspector",
264
+ (get, set, id) => {
265
+ set(setScaffoldInspectorOpenAtom(id), !get(scaffoldInspectorOpenAtom(id)))
266
+ }
267
+ )
268
+
269
+ // ── actions: panels ────────────────────────────────────────────────────────
270
+
271
+ /** Show/hide an id'd panel. Showing past capacity auto-hides the
272
+ * least-recently-activated visible panel; the last visible one never hides. */
273
+ export const toggleScaffoldPanelAtom = actionFamily<[panelId: string]>(
274
+ "togglePanel",
275
+ (get, set, id, panelId) => {
276
+ const ids = get(scaffoldPanelIdsAtom(id))
277
+ if (!ids.includes(panelId)) return
278
+ const prev = get(scaffoldVisibilityAtom(id))
279
+ const hidden =
280
+ prev.autoHidden.includes(panelId) || prev.userHidden.includes(panelId)
281
+ if (!hidden && prev.visibleOrder.length <= 1) return
282
+
283
+ const next: ScaffoldPanelVisibility = hidden
284
+ ? {
285
+ // Showing: newest activation — reconcile evicts the oldest
286
+ // visible panel if the canvas can't fit one more.
287
+ visibleOrder: [...prev.visibleOrder, panelId],
288
+ autoHidden: prev.autoHidden.filter((other) => other !== panelId),
289
+ userHidden: prev.userHidden.filter((other) => other !== panelId),
290
+ }
291
+ : {
292
+ visibleOrder: prev.visibleOrder.filter((other) => other !== panelId),
293
+ autoHidden: prev.autoHidden,
294
+ userHidden: [...prev.userHidden, panelId],
295
+ }
296
+ set(
297
+ scaffoldVisibilityAtom(id),
298
+ reconcileVisibility(next, ids, get(scaffoldPanelCapacityAtom(id)))
299
+ )
300
+ }
301
+ )
302
+
303
+ /** @internal Tabs report pointer enter/leave on their label. Leaving clears
304
+ * only its own id — a tab that unmounts mid-hover must not wipe out a hover
305
+ * the pointer has already moved on to. */
306
+ export const setScaffoldPanelHoveredAtom = actionFamily<
307
+ [panelId: string, hovered: boolean]
308
+ >("setPanelHovered", (get, set, id, panelId, hovered) => {
309
+ const prev = get(scaffoldHoveredPanelIdAtom(id))
310
+ if (hovered) {
311
+ if (prev !== panelId) set(scaffoldHoveredPanelIdAtom(id), panelId)
312
+ return
313
+ }
314
+ if (prev === panelId) set(scaffoldHoveredPanelIdAtom(id), null)
315
+ })
316
+
317
+ /** @internal Canvas reports its ordered panel ids each render. Inert when
318
+ * the ids did not move; otherwise the visibility settles in the same write. */
319
+ export const registerScaffoldPanelIdsAtom = actionFamily<
320
+ [ids: readonly string[]]
321
+ >("registerPanelIds", (get, set, id, ids) => {
322
+ const prev = get(scaffoldPanelIdsAtom(id))
323
+ if (prev.length === ids.length && prev.every((v, i) => v === ids[i])) return
324
+ set(scaffoldPanelIdsAtom(id), [...ids])
325
+ settleVisibility(get, set, id)
326
+ })
327
+
328
+ /** @internal Canvas reports its measured width. Only the derived capacity is
329
+ * stored, so resize storms settle to at most one visibility change. */
330
+ export const measureScaffoldCanvasAtom = actionFamily<[width: number]>(
331
+ "measureCanvas",
332
+ (get, set, id, width) => {
333
+ const capacity = getPanelCapacity(width)
334
+ if (get(scaffoldMeasuredCapacityAtom(id)) === capacity) return
335
+ set(scaffoldMeasuredCapacityAtom(id), capacity)
336
+ settleVisibility(get, set, id)
337
+ }
338
+ )
339
+
340
+ // ── internal projections ───────────────────────────────────────────────────
341
+
342
+ /** @internal Projection of the mounted root's controlled props. */
343
+ export const projectScaffoldPropsAtom = actionFamily<
344
+ [config: ScaffoldConfig, inspectorOpen: boolean | undefined]
345
+ >("projectProps", (_get, set, id, config, inspectorOpen) => {
346
+ set(scaffoldConfigAtom(id), config)
347
+ if (config.controlsInspector && inspectorOpen !== undefined)
348
+ set(scaffoldInspectorOpenAtom(id), inspectorOpen)
349
+ })
350
+
351
+ /** @internal */
352
+ export const setScaffoldHandlersAtom = actionFamily<
353
+ [handlers: ScaffoldHandlers]
354
+ >("setHandlers", (_get, set, id, handlers) => {
355
+ set(scaffoldHandlersAtom(id), handlers)
356
+ })
357
+
358
+ /** @internal Seed the uncontrolled inspector on mount without firing
359
+ * `onInspectorOpenChange`. */
360
+ export const seedScaffoldInspectorAtom = actionFamily<[open: boolean]>(
361
+ "seedInspector",
362
+ (_get, set, id, open) => {
363
+ set(scaffoldInspectorOpenAtom(id), open)
364
+ }
365
+ )
366
+
367
+ // ── reset ──────────────────────────────────────────────────────────────────
368
+
369
+ export const resetScaffoldAtom = actionFamily<[]>("reset", (_get, set, id) => {
370
+ set(scaffoldInspectorOpenAtom(id), false)
371
+ set(scaffoldPanelIdsAtom(id), NO_PANEL_IDS)
372
+ set(scaffoldMeasuredCapacityAtom(id), null)
373
+ set(scaffoldVisibilityAtom(id), EMPTY_SCAFFOLD_VISIBILITY)
374
+ set(scaffoldHoveredPanelIdAtom(id), null)
375
+ set(scaffoldConfigAtom(id), DEFAULT_CONFIG)
376
+ set(scaffoldHandlersAtom(id), NO_HANDLERS)
377
+ })
378
+
379
+ /** Forget a scaffold entirely — every family drops the key so the store can
380
+ * release its state. Call on teardown of a named instance. */
381
+ export function removeScaffoldInstance(id: ScaffoldInstanceId): void {
382
+ for (const family of families) family.remove(id)
383
+ }
@@ -2,8 +2,14 @@
2
2
 
3
3
  import { AnimatePresence } from "motion/react"
4
4
  import * as React from "react"
5
+ import { useAtomValue, useSetAtom } from "jotai"
5
6
 
6
7
  import { cn } from "@/lib/utils"
8
+ import {
9
+ measureScaffoldCanvasAtom,
10
+ registerScaffoldPanelIdsAtom,
11
+ scaffoldHiddenPanelIdsAtom,
12
+ } from "./scaffold-atom"
7
13
  import { useScaffoldContext } from "./scaffold-context"
8
14
  import type { ScaffoldCanvasProps, ScaffoldPanelProps } from "./type"
9
15
 
@@ -26,19 +32,21 @@ export function ScaffoldCanvas({
26
32
  children,
27
33
  ...rest
28
34
  }: ScaffoldCanvasProps): React.ReactElement {
29
- const { hiddenPanelIds, registerPanelIds, setCanvasWidth } =
30
- useScaffoldContext()
35
+ const { scaffoldId } = useScaffoldContext()
36
+ const hiddenPanelIds = useAtomValue(scaffoldHiddenPanelIdsAtom(scaffoldId))
37
+ const registerPanelIds = useSetAtom(registerScaffoldPanelIdsAtom(scaffoldId))
38
+ const measureCanvas = useSetAtom(measureScaffoldCanvasAtom(scaffoldId))
31
39
  const ref = React.useRef<HTMLDivElement>(null)
32
40
 
33
41
  React.useLayoutEffect(() => {
34
42
  const element = ref.current
35
43
  if (!element || typeof ResizeObserver === "undefined") return
36
44
  const observer = new ResizeObserver(([entry]) =>
37
- setCanvasWidth(entry.contentRect.width)
45
+ measureCanvas(entry.contentRect.width)
38
46
  )
39
47
  observer.observe(element)
40
48
  return () => observer.disconnect()
41
- }, [setCanvasWidth])
49
+ }, [measureCanvas])
42
50
 
43
51
  const childArray = React.Children.toArray(children)
44
52
  const panelIds = childArray
@@ -8,7 +8,9 @@ export const ScaffoldContext = React.createContext<ScaffoldContextValue | null>(
8
8
  null
9
9
  )
10
10
 
11
- /** Read the scaffold's shared state; must run under `Scaffold.Root`. */
11
+ /** Read the scaffold's identity (`scaffoldId`, `inspectorWidth`); must run
12
+ * under `Scaffold.Root`. State itself lives in the store — subscribe to the
13
+ * atoms, or reach them by id through `useScaffoldState` / `useScaffoldActions`. */
12
14
  export function useScaffoldContext(): ScaffoldContextValue {
13
15
  const context = React.useContext(ScaffoldContext)
14
16
  if (!context) {