@weasel-js/labkit 1.4.3 → 1.5.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 (229) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-8R6UmlvN.d.ts} +256 -33
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-DelFfeV0.d.ts → index-retnAut7.d.ts} +45 -12
  7. package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
  8. package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
  9. package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
  10. package/dist/canvas/index.d.ts +17 -128
  11. package/dist/canvas/index.js +4 -2
  12. package/dist/chrome/index.d.ts +4 -92
  13. package/dist/chrome/index.js +9 -5
  14. package/dist/chunk-67SJLMC7.js +704 -0
  15. package/dist/chunk-67SJLMC7.js.map +1 -0
  16. package/dist/chunk-6ZDGOZQV.js +310 -0
  17. package/dist/chunk-6ZDGOZQV.js.map +1 -0
  18. package/dist/{chunk-QAGAHJKZ.js → chunk-EKIICY6X.js} +111 -25
  19. package/dist/chunk-EKIICY6X.js.map +1 -0
  20. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  21. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  22. package/dist/{chunk-2RQPGOQF.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-ISCOFAUO.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-ISCOFAUO.js.map → chunk-MUKOW3TC.js.map} +1 -1
  26. package/dist/chunk-NDRYLVOW.js +15 -0
  27. package/dist/chunk-NDRYLVOW.js.map +1 -0
  28. package/dist/{chunk-C42T6DJT.js → chunk-NS64DXMN.js} +226 -159
  29. package/dist/chunk-NS64DXMN.js.map +1 -0
  30. package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
  31. package/dist/chunk-PGETSEDK.js.map +1 -0
  32. package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
  33. package/dist/chunk-QLSV2N3G.js.map +1 -0
  34. package/dist/chunk-RWNDDKOH.js +682 -0
  35. package/dist/chunk-RWNDDKOH.js.map +1 -0
  36. package/dist/chunk-S2SHZRD7.js +7681 -0
  37. package/dist/chunk-S2SHZRD7.js.map +1 -0
  38. package/dist/{chunk-ASWKLRKJ.js → chunk-TQBBYTAF.js} +19 -82
  39. package/dist/chunk-TQBBYTAF.js.map +1 -0
  40. package/dist/chunk-UU3NJO6N.js +72 -0
  41. package/dist/chunk-UU3NJO6N.js.map +1 -0
  42. package/dist/chunk-WIG6XHJ7.js +35 -0
  43. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  44. package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
  45. package/dist/chunk-ZPH5WTWF.js.map +1 -0
  46. package/dist/config/index.d.ts +232 -0
  47. package/dist/config/index.js +6 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/controls/index.d.ts +2 -3
  50. package/dist/controls/index.js +3 -2
  51. package/dist/dragdrop/index.d.ts +2 -4
  52. package/dist/index.d.ts +123 -348
  53. package/dist/index.js +794 -837
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +3 -5
  56. package/dist/layers/index.js +2 -2
  57. package/dist/loupe/index.d.ts +6 -8
  58. package/dist/loupe/index.js +2 -2
  59. package/dist/passthrough/weasel-ui.d.ts +169 -117
  60. package/dist/passthrough/weasel-ui.js +1 -1
  61. package/dist/primitives/index.d.ts +53 -6
  62. package/dist/primitives/index.js +6 -4
  63. package/dist/state/index.d.ts +12 -7
  64. package/dist/state/index.js +16 -13
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/styles.css +163 -62
  67. package/dist/surface/index.d.ts +44 -7
  68. package/dist/surface/index.js +2 -1
  69. package/dist/ui/layers/index.js +1 -1
  70. package/dist/undo/index.d.ts +2 -4
  71. package/package.json +15 -8
  72. package/src/annotations/AnnotationOverlay.tsx +53 -5
  73. package/src/annotations/Annotations.overlay.test.tsx +85 -6
  74. package/src/annotations/drawOne.test.ts +1 -1
  75. package/src/annotations/drawOne.ts +2 -2
  76. package/src/annotations/frac.test.ts +4 -4
  77. package/src/annotations/frac.ts +3 -2
  78. package/src/annotations/index.ts +1 -1
  79. package/src/annotations/paint.test.ts +2 -2
  80. package/src/annotations/paint.ts +1 -1
  81. package/src/annotations/preload.ts +6 -0
  82. package/src/annotations/store.test.ts +18 -0
  83. package/src/annotations/store.ts +7 -6
  84. package/src/annotations/svgNodes.test.ts +2 -2
  85. package/src/annotations/svgNodes.ts +1 -2
  86. package/src/annotations/toolMap.ts +3 -3
  87. package/src/annotations/types.ts +6 -3
  88. package/src/canvas/CameraWheelContext.ts +12 -0
  89. package/src/canvas/CanvasStack.test.tsx +25 -0
  90. package/src/canvas/CanvasStack.tsx +31 -4
  91. package/src/canvas/Stage.less +18 -0
  92. package/src/canvas/Stage.test.tsx +186 -0
  93. package/src/canvas/Stage.tsx +134 -0
  94. package/src/canvas/camera.test.ts +17 -2
  95. package/src/canvas/camera.ts +9 -9
  96. package/src/canvas/index.ts +4 -0
  97. package/src/canvas/usePanZoom.ts +10 -5
  98. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  99. package/src/chrome/LabChrome.tsx +114 -0
  100. package/src/chrome/builtins.tsx +20 -31
  101. package/src/chrome/index.ts +15 -0
  102. package/src/chrome/labRegions.test.tsx +208 -0
  103. package/src/chrome/labTypes.ts +50 -0
  104. package/src/chrome/merge.ts +7 -9
  105. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  106. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  107. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  108. package/src/chrome/regions/StatusRegion.tsx +15 -8
  109. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  110. package/src/chrome/types.ts +47 -20
  111. package/src/config/builder.test.ts +4 -0
  112. package/src/config/builder.ts +16 -10
  113. package/src/config/declarationEmit.fixture.ts +11 -0
  114. package/src/config/declarationEmit.test.ts +51 -0
  115. package/src/config/entry.test.ts +46 -0
  116. package/src/config/index.ts +13 -1
  117. package/src/config/nodeClasses.test.ts +18 -0
  118. package/src/config/types.ts +3 -1
  119. package/src/controls/ControlPanel.test.tsx +16 -0
  120. package/src/controls/ControlPanel.tsx +5 -2
  121. package/src/fake-indexeddb-auto.d.ts +5 -0
  122. package/src/index.test.ts +7 -0
  123. package/src/index.ts +39 -6
  124. package/src/instrument/serializers.test.ts +22 -0
  125. package/src/instrument/serializers.ts +11 -0
  126. package/src/instrument/types.ts +25 -1
  127. package/src/lab/Lab.less +4 -13
  128. package/src/lab/Lab.persist.test.tsx +147 -0
  129. package/src/lab/Lab.stories.tsx +0 -2
  130. package/src/lab/Lab.surface.test.tsx +66 -3
  131. package/src/lab/Lab.test.tsx +91 -17
  132. package/src/lab/Lab.tsx +337 -124
  133. package/src/lab/LabFit.stories.less +18 -0
  134. package/src/lab/LabFit.stories.tsx +219 -0
  135. package/src/lab/LabFullChrome.stories.tsx +0 -3
  136. package/src/lab/LabHeader.test.tsx +40 -9
  137. package/src/lab/LabHeader.tsx +7 -13
  138. package/src/lab/LabPalette.tsx +13 -26
  139. package/src/lab/LabShell.less +49 -12
  140. package/src/lab/LabShell.tsx +14 -2
  141. package/src/lab/LabSwitcher.less +92 -0
  142. package/src/lab/LabSwitcher.test.tsx +96 -0
  143. package/src/lab/LabSwitcher.tsx +112 -0
  144. package/src/lab/Workspace.surface.test.tsx +2 -1
  145. package/src/lab/fitCheck.test.ts +57 -0
  146. package/src/lab/fitCheck.ts +125 -0
  147. package/src/lab/index.ts +2 -0
  148. package/src/loupe/AGENTS.md +1 -1
  149. package/src/loupe/Loupe.less +1 -1
  150. package/src/loupe/canvasLens.ts +1 -1
  151. package/src/loupe/types.ts +1 -1
  152. package/src/loupe/useLoupe.test.tsx +1 -1
  153. package/src/loupe/useLoupe.ts +1 -1
  154. package/src/passthrough/weasel-ui.ts +9 -0
  155. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  156. package/src/primitives/FloatingPanel.test.tsx +47 -16
  157. package/src/primitives/FloatingPanel.tsx +11 -25
  158. package/src/primitives/Split.less +14 -0
  159. package/src/primitives/Split.test.tsx +108 -0
  160. package/src/primitives/Split.tsx +159 -0
  161. package/src/primitives/ZoomControl.tsx +5 -2
  162. package/src/primitives/index.ts +2 -0
  163. package/src/state/Persistence.stories.tsx +75 -0
  164. package/src/state/Persistence.tsx +33 -0
  165. package/src/state/SingletonExperiment.test.tsx +47 -53
  166. package/src/state/SingletonExperiment.tsx +24 -13
  167. package/src/state/adapterContract.ts +116 -0
  168. package/src/state/adapters.test.ts +113 -61
  169. package/src/state/adapters.ts +328 -69
  170. package/src/state/document.test.ts +71 -96
  171. package/src/state/document.ts +66 -52
  172. package/src/state/helpers.test.ts +20 -0
  173. package/src/state/helpers.ts +16 -0
  174. package/src/state/index.ts +8 -0
  175. package/src/state/labRecords.test.ts +105 -0
  176. package/src/state/labRecords.ts +160 -0
  177. package/src/state/openLabStore.test.ts +658 -0
  178. package/src/state/openLabStore.ts +403 -0
  179. package/src/state/records.test.ts +234 -0
  180. package/src/state/records.ts +219 -0
  181. package/src/state/store.test.ts +104 -635
  182. package/src/state/store.ts +117 -158
  183. package/src/state/toolSlot.test.ts +1 -1
  184. package/src/state/types.ts +39 -15
  185. package/src/state/useOpenOnce.ts +68 -0
  186. package/src/state/usePersistedState.test.tsx +125 -0
  187. package/src/state/usePersistedState.ts +66 -0
  188. package/src/state/useTrialState.test.tsx +2 -3
  189. package/src/state/view.test.ts +49 -15
  190. package/src/state/view.ts +18 -1
  191. package/src/styles.less +3 -0
  192. package/src/surface/AGENTS.md +39 -7
  193. package/src/surface/SurfaceContext.ts +26 -3
  194. package/src/surface/index.ts +2 -0
  195. package/src/surface/useSurfaceTile.test.tsx +2 -1
  196. package/src/surface/useSurfaceTile.ts +6 -5
  197. package/src/surface/useTiledSurface.test.tsx +175 -0
  198. package/src/surface/useTiledSurface.ts +54 -5
  199. package/src/theme/Interstellar.stories.tsx +7 -7
  200. package/src/theme/base.less +4 -4
  201. package/src/theme/interstellar.test.ts +1 -1
  202. package/src/theme/interstellar.tokens.json +2 -2
  203. package/src/tools/labTool.ts +11 -0
  204. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  205. package/src/trial/Trial.annotations.test.tsx +47 -4
  206. package/src/trial/Trial.job.test.tsx +3 -3
  207. package/src/trial/Trial.less +1 -26
  208. package/src/trial/Trial.stories.tsx +1 -2
  209. package/src/trial/Trial.test.tsx +28 -4
  210. package/src/trial/Trial.trialId.test.tsx +23 -1
  211. package/src/trial/Trial.tsx +118 -38
  212. package/src/trial/TrialBody.tsx +17 -141
  213. package/src/trial/TrialChrome.tsx +7 -4
  214. package/src/trial/trialOps.ts +7 -4
  215. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  216. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  217. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  218. package/dist/chunk-2RQPGOQF.js.map +0 -1
  219. package/dist/chunk-5TROQVQ2.js +0 -547
  220. package/dist/chunk-5TROQVQ2.js.map +0 -1
  221. package/dist/chunk-ASWKLRKJ.js.map +0 -1
  222. package/dist/chunk-C42T6DJT.js.map +0 -1
  223. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  224. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  225. package/dist/chunk-I6JCVE24.js.map +0 -1
  226. package/dist/chunk-MHAGC6VD.js +0 -7320
  227. package/dist/chunk-MHAGC6VD.js.map +0 -1
  228. package/dist/chunk-QAGAHJKZ.js.map +0 -1
  229. package/dist/chunk-TJ7QY3OC.js.map +0 -1
@@ -1,23 +1,17 @@
1
1
  import { createStore, type StoreApi } from 'zustand/vanilla';
2
2
  import { fillConfigDefaults, withValueAtPath } from '../config/path';
3
- import {
4
- CURRENT_DOCUMENT_VERSION,
5
- deleteLegacyKeys,
6
- emptyDocument,
7
- labDocumentKey,
8
- MIGRATIONS,
9
- normalizeDocument,
10
- quarantineDocument,
11
- readLegacyDocument,
12
- runMigrations,
13
- } from './document';
14
- import { deserializeTrials, emptyUndoStack, serializeTrials } from './helpers';
3
+ import { configDefaultsOf, configMigrationsOf, serializersOf } from '../instrument/serializers';
4
+ import type { InstrumentList } from '../instrument/types';
5
+ import { emptyDocument } from './document';
6
+ import { deserializeTrials, emptyUndoStack, newId } from './helpers';
15
7
  import type {
16
8
  CreateLabStoreOptions,
17
- LabDocument,
9
+ InstrumentHooks,
10
+ InstrumentSerializers,
18
11
  LabMode,
19
12
  LabStoreState,
20
13
  SavedSnapshot,
14
+ SerializedTrial,
21
15
  TrialRecord,
22
16
  } from './types';
23
17
  import {
@@ -59,54 +53,44 @@ export interface LabStoreActions {
59
53
  undockPanel: (trialId: string, sectionId: string, as?: UndockedPanel['as']) => void;
60
54
  /** Put one section back, or — with no `sectionId` — every panel the trial owns. */
61
55
  dockPanel: (trialId: string, sectionId?: string) => void;
56
+ /** Replace the lab's instruments. Trials and saves of an instrument that is
57
+ * new or a different object are brought up to date against it in the same
58
+ * write; every other trial keeps its object. */
59
+ setInstruments: (instruments: InstrumentList) => void;
60
+ /** The per-instrument hooks in force now. Read them at use; never keep them. */
61
+ instrumentHooks: () => InstrumentHooks;
62
62
  }
63
63
 
64
64
  /** A lab's store: its state and its actions. How each instrument's state is
65
- * serialized comes in through `CreateLabStoreOptions.serializers`. */
65
+ * serialized comes in through `CreateLabStoreOptions.instruments`. */
66
66
  export type LabStore = StoreApi<LabStoreState & LabStoreActions>;
67
67
 
68
- /** Build a lab store, hydrating from storage if anything was saved under the
69
- * same key. Writes back are debounced. */
70
- export function createLabStore(options: CreateLabStoreOptions): LabStore {
71
- const serializers = options.serializers ?? {};
72
- let flushTimer: ReturnType<typeof setTimeout> | null = null;
73
-
74
- const hydration = hydrateDocument(options);
75
- const hydrated = hydration.document;
76
- const persistDisabled = hydration.persistDisabled;
77
- // Cleared once the legacy keys are actually gone; see the flush.
78
- let foldedFromLegacy = hydration.foldedFromLegacy;
79
-
80
- const configDefaults = options.configDefaults ?? {};
81
- const filled = (instrumentName: string, config: unknown): unknown => {
82
- const defaults = configDefaults[instrumentName];
83
- return defaults ? fillConfigDefaults(config, defaults()) : config;
84
- };
85
-
86
- const hydratedTrials = deserializeTrials(
87
- hydrated.trials.map((t) => ({ ...t, config: filled(t.instrumentName, t.config) })),
88
- serializers,
89
- );
90
- const hydratedSnapshots = hydrated.saves.map((sn) => ({
91
- ...sn,
92
- config: filled(sn.instrumentName, sn.config),
93
- }));
94
- const hydratedLayout = hydrated.layout;
95
- const hydratedMode = hydrated.mode;
68
+ /** Build a lab store holding `initial`, or an empty lab. It knows nothing
69
+ * about storage; `openLabStore` reads a stored lab and binds one of these to
70
+ * it. */
71
+ export function createLabStore(options: CreateLabStoreOptions = {}): LabStore {
72
+ let hooks: InstrumentHooks = options.instruments
73
+ ? hooksOf(options.instruments)
74
+ : {
75
+ serializers: options.serializers ?? {},
76
+ configDefaults: options.configDefaults ?? {},
77
+ configMigrations: options.configMigrations ?? {},
78
+ };
79
+ const initial = options.initial ?? emptyDocument(options.initialMode ?? 'auto');
96
80
 
97
81
  const store = createStore<LabStoreState & LabStoreActions>()((set, get) => ({
98
- trials: hydratedTrials,
99
- savedSnapshots: hydratedSnapshots,
100
- mode: hydratedMode,
101
- layout: hydratedLayout,
102
- undockedPanels: hydrated.undockedPanels,
82
+ trials: hydrateTrials(initial.trials, hooks.serializers, hooks),
83
+ savedSnapshots: hydrateSnapshots(initial.saves, hooks),
84
+ mode: initial.mode,
85
+ layout: initial.layout,
86
+ undockedPanels: initial.undockedPanels,
103
87
  activeToolId: null,
88
+ instruments: options.instruments ?? null,
104
89
 
105
90
  addTrial: (record) => {
106
91
  set((s) => ({
107
92
  trials: [...s.trials, { ...record, undoStack: emptyUndoStack() }],
108
93
  }));
109
- scheduleFlush();
110
94
  },
111
95
 
112
96
  removeTrial: (id) => {
@@ -114,7 +98,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
114
98
  trials: s.trials.filter((w) => w.id !== id),
115
99
  undockedPanels: dockPanelIn(s.undockedPanels, id),
116
100
  }));
117
- scheduleFlush();
118
101
  },
119
102
 
120
103
  updateTrialState: (id, next) => {
@@ -130,7 +113,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
130
113
  return { ...w, state: nextState };
131
114
  }),
132
115
  }));
133
- scheduleFlush();
134
116
  },
135
117
 
136
118
  updateTrialConfig: (id, path, value) => {
@@ -139,14 +121,12 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
139
121
  w.id === id ? { ...w, config: withValueAtPath(w.config, path, value) } : w,
140
122
  ),
141
123
  }));
142
- scheduleFlush();
143
124
  },
144
125
 
145
126
  updateTrialView: (id, view) => {
146
127
  set((s) => ({
147
128
  trials: s.trials.map((w) => (w.id === id && !Object.is(view, w.view) ? { ...w, view } : w)),
148
129
  }));
149
- scheduleFlush();
150
130
  },
151
131
 
152
132
  updateTrialSidebarWidth: (id, width) => {
@@ -155,7 +135,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
155
135
  w.id === id && w.sidebarWidth !== width ? { ...w, sidebarWidth: width } : w,
156
136
  ),
157
137
  }));
158
- scheduleFlush();
159
138
  },
160
139
 
161
140
  setTrialTitle: (id, title) => {
@@ -167,7 +146,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
167
146
  return { ...w, title: next };
168
147
  }),
169
148
  }));
170
- scheduleFlush();
171
149
  },
172
150
 
173
151
  setTrialSectionCollapsed: (id, key, collapsed) => {
@@ -178,7 +156,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
178
156
  return { ...w, collapsedSections: { ...w.collapsedSections, [key]: collapsed } };
179
157
  }),
180
158
  }));
181
- scheduleFlush();
182
159
  },
183
160
 
184
161
  updateTrialAnnotations: (id, doc) => {
@@ -187,7 +164,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
187
164
  w.id === id && !Object.is(doc, w.annotations) ? { ...w, annotations: doc } : w,
188
165
  ),
189
166
  }));
190
- scheduleFlush();
191
167
  },
192
168
 
193
169
  updateTrialUndoStack: (id, next) => {
@@ -207,13 +183,12 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
207
183
  set((s) => ({
208
184
  trials: s.trials.map((w) => (w.id === id ? { ...w, instrumentName } : w)),
209
185
  }));
210
- scheduleFlush();
211
186
  },
212
187
 
213
188
  saveSnapshot: (trialId, name) => {
214
189
  const trial = get().trials.find((w) => w.id === trialId);
215
190
  if (!trial) return;
216
- const reg = serializers[trial.instrumentName];
191
+ const reg = hooks.serializers[trial.instrumentName];
217
192
  const serializedState = reg?.serialize
218
193
  ? reg.serialize(trial.state)
219
194
  : structuredClone(trial.state);
@@ -221,7 +196,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
221
196
  const lastAt = get().savedSnapshots.reduce((m, sn) => (sn.savedAt > m ? sn.savedAt : m), 0);
222
197
  const savedAt = Math.max(Date.now(), lastAt + 1);
223
198
  const snapshot: SavedSnapshot = {
224
- id: crypto.randomUUID(),
199
+ id: newId(),
225
200
  name,
226
201
  trialId,
227
202
  instrumentName: trial.instrumentName,
@@ -230,7 +205,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
230
205
  savedAt,
231
206
  };
232
207
  set((s) => ({ savedSnapshots: [...s.savedSnapshots, snapshot] }));
233
- scheduleFlush();
234
208
  },
235
209
 
236
210
  loadSnapshot: (snapshotId, trialId) => {
@@ -244,7 +218,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
244
218
  );
245
219
  return;
246
220
  }
247
- const reg = serializers[snapshot.instrumentName];
221
+ const reg = hooks.serializers[snapshot.instrumentName];
248
222
  const restoredState = reg?.deserialize
249
223
  ? reg.deserialize(snapshot.state, snapshot.config)
250
224
  : snapshot.state;
@@ -253,14 +227,12 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
253
227
  w.id === trialId ? { ...w, state: restoredState, config: snapshot.config } : w,
254
228
  ),
255
229
  }));
256
- scheduleFlush();
257
230
  },
258
231
 
259
232
  deleteSnapshot: (snapshotId) => {
260
233
  set((s) => ({
261
234
  savedSnapshots: s.savedSnapshots.filter((sn) => sn.id !== snapshotId),
262
235
  }));
263
- scheduleFlush();
264
236
  },
265
237
 
266
238
  listSnapshots: (trialId) => {
@@ -271,7 +243,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
271
243
 
272
244
  setMode: (mode) => {
273
245
  set({ mode });
274
- scheduleFlush();
275
246
  },
276
247
 
277
248
  setLabTool: (id) => set({ activeToolId: id }),
@@ -283,119 +254,107 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
283
254
 
284
255
  undockPanel: (trialId, sectionId, as) => {
285
256
  set((s) => ({ undockedPanels: undockPanelIn(s.undockedPanels, trialId, sectionId, as) }));
286
- scheduleFlush();
287
257
  },
288
258
 
289
259
  dockPanel: (trialId, sectionId) => {
290
260
  set((s) => ({ undockedPanels: dockPanelIn(s.undockedPanels, trialId, sectionId) }));
291
- scheduleFlush();
292
261
  },
293
262
 
294
263
  setLayout: (layout) => {
295
264
  set({ layout });
296
- scheduleFlush();
297
265
  },
298
- }));
299
266
 
300
- function scheduleFlush(): void {
301
- if (persistDisabled) return;
302
- if (flushTimer) clearTimeout(flushTimer);
303
- flushTimer = setTimeout(() => {
304
- const s = store.getState();
305
- const document: LabDocument = {
306
- version: CURRENT_DOCUMENT_VERSION,
307
- trials: serializeTrials(s.trials, serializers),
308
- saves: s.savedSnapshots,
309
- layout: s.layout,
310
- undockedPanels: s.undockedPanels,
311
- mode: s.mode,
312
- };
313
- const serialized = JSON.stringify(document);
314
- options.storage.write(labDocumentKey(options.storageKey), serialized);
315
- if (foldedFromLegacy && deleteLegacyKeys(options.storage, options.storageKey, serialized)) {
316
- foldedFromLegacy = false;
267
+ setInstruments: (instruments) => {
268
+ const before = get().instruments;
269
+ if (before === instruments) return;
270
+ const next = mergedHooks(hooks, instruments);
271
+ const previous = new Map((before ?? []).map((i) => [i.name, i]));
272
+ const changed = new Set(
273
+ instruments.filter((i) => previous.get(i.name) !== i).map((i) => i.name),
274
+ );
275
+ if (changed.size === 0) {
276
+ hooks = next;
277
+ set({ instruments });
278
+ return;
317
279
  }
318
- flushTimer = null;
319
- }, 300);
320
- }
280
+ const s = get();
281
+ const trials = s.trials.map((w) =>
282
+ changed.has(w.instrumentName)
283
+ ? { ...w, config: hydratedConfig(next, w.instrumentName, w.config) }
284
+ : w,
285
+ );
286
+ const savedSnapshots = s.savedSnapshots.map((sn) =>
287
+ changed.has(sn.instrumentName)
288
+ ? { ...sn, config: hydratedConfig(next, sn.instrumentName, sn.config) }
289
+ : sn,
290
+ );
291
+ hooks = next;
292
+ set({ instruments, trials, savedSnapshots });
293
+ },
321
294
 
322
- // A lab opened and closed without a single mutation still completes its
323
- // fold; the flush is what removes the legacy keys.
324
- if (foldedFromLegacy) scheduleFlush();
295
+ instrumentHooks: () => hooks,
296
+ }));
325
297
 
326
298
  return store;
327
299
  }
328
300
 
329
- interface HydrateResult {
330
- document: LabDocument;
331
- /** True when flushing would destroy the only copy of something: a document
332
- * newer than this code understands, or an unusable one whose quarantine
333
- * copy did not land. */
334
- persistDisabled: boolean;
335
- foldedFromLegacy: boolean;
301
+ function hooksOf(instruments: InstrumentList): InstrumentHooks {
302
+ return {
303
+ serializers: serializersOf(instruments),
304
+ configDefaults: configDefaultsOf(instruments),
305
+ configMigrations: configMigrationsOf(instruments),
306
+ };
336
307
  }
337
308
 
338
- /** Copy an unusable document aside, and report whether the store may go on
339
- * persisting. A failed quarantine write means the copy in storage is the only
340
- * one there is, so the store must not overwrite it. */
341
- function setAside(
342
- options: CreateLabStoreOptions,
343
- raw: string,
344
- why: string,
345
- error?: unknown,
346
- ): boolean {
347
- const quarantined = quarantineDocument(options.storage, options.storageKey, raw);
348
- const message = quarantined
349
- ? `[labkit] ${why}; quarantined it and starting empty`
350
- : `[labkit] ${why} and could not be quarantined; leaving it in place and not persisting`;
351
- if (error === undefined) console.warn(message);
352
- else console.warn(message, error);
353
- return quarantined;
309
+ /** Hooks for `instruments`, keeping those of any instrument no longer listed:
310
+ * its trials stay open, and their state still has to round-trip. A listed
311
+ * name takes its hooks wholly from the new list. */
312
+ function mergedHooks(old: InstrumentHooks, instruments: InstrumentList): InstrumentHooks {
313
+ const listed = new Set(instruments.map((i) => i.name));
314
+ const fresh = hooksOf(instruments);
315
+ const unlisted = <T>(entries: Record<string, T>): Record<string, T> =>
316
+ Object.fromEntries(Object.entries(entries).filter(([name]) => !listed.has(name)));
317
+ return {
318
+ serializers: { ...unlisted(old.serializers), ...fresh.serializers },
319
+ configDefaults: { ...unlisted(old.configDefaults), ...fresh.configDefaults },
320
+ configMigrations: { ...unlisted(old.configMigrations), ...fresh.configMigrations },
321
+ };
354
322
  }
355
323
 
356
- function hydrateDocument(options: CreateLabStoreOptions): HydrateResult {
357
- const fallback = emptyDocument(options.initialMode ?? 'auto');
358
- const raw = options.storage.read(labDocumentKey(options.storageKey));
359
-
360
- let parsed: Record<string, unknown> | null = null;
361
- if (raw !== null) {
362
- try {
363
- parsed = JSON.parse(raw) as Record<string, unknown>;
364
- } catch {
365
- const persistDisabled = !setAside(options, raw, 'lab document is unparseable');
366
- return { document: fallback, persistDisabled, foldedFromLegacy: false };
367
- }
368
- } else {
369
- parsed = readLegacyDocument(options.storage, options.storageKey, options.initialMode ?? 'auto');
370
- }
371
-
372
- if (parsed === null) {
373
- return { document: fallback, persistDisabled: false, foldedFromLegacy: false };
374
- }
375
-
376
- const foldedFromLegacy = raw === null;
377
- const outcome = runMigrations(parsed, MIGRATIONS, CURRENT_DOCUMENT_VERSION);
378
-
379
- if (!outcome.ok) {
380
- if (outcome.reason === 'future') {
381
- console.warn(
382
- '[labkit] lab document is from a newer version of labkit; starting empty and leaving it alone',
383
- );
384
- return { document: fallback, persistDisabled: true, foldedFromLegacy: false };
385
- }
386
- const stored = JSON.stringify(parsed);
387
- const persistDisabled = !setAside(
388
- options,
389
- stored,
390
- 'lab document failed to migrate',
391
- outcome.error,
392
- );
393
- return { document: fallback, persistDisabled, foldedFromLegacy: false };
394
- }
324
+ /** How a stored config is brought up to date on the way in. */
325
+ export type ConfigHydration = Pick<CreateLabStoreOptions, 'configDefaults' | 'configMigrations'>;
326
+
327
+ function hydratedConfig(
328
+ hydration: ConfigHydration,
329
+ instrumentName: string,
330
+ config: unknown,
331
+ ): unknown {
332
+ const migrate = hydration.configMigrations?.[instrumentName];
333
+ const moved = migrate ? migrate(config) : config;
334
+ const defaults = hydration.configDefaults?.[instrumentName];
335
+ return defaults ? fillConfigDefaults(moved, defaults()) : moved;
336
+ }
395
337
 
396
- return {
397
- document: normalizeDocument(outcome.doc, options.initialMode ?? 'auto'),
398
- persistDisabled: false,
399
- foldedFromLegacy,
400
- };
338
+ /** Rebuild stored trials: migrate each config, fill its gaps from its
339
+ * instrument's defaults, then run the deserializer against the result. */
340
+ export function hydrateTrials(
341
+ trials: SerializedTrial[],
342
+ serializers: InstrumentSerializers,
343
+ hydration: ConfigHydration = {},
344
+ ): TrialRecord[] {
345
+ return deserializeTrials(
346
+ trials.map((t) => ({ ...t, config: hydratedConfig(hydration, t.instrumentName, t.config) })),
347
+ serializers,
348
+ );
349
+ }
350
+
351
+ /** Bring each stored snapshot's config up to date the way `hydrateTrials` does a trial's. */
352
+ export function hydrateSnapshots(
353
+ saves: SavedSnapshot[],
354
+ hydration: ConfigHydration = {},
355
+ ): SavedSnapshot[] {
356
+ return saves.map((sn) => ({
357
+ ...sn,
358
+ config: hydratedConfig(hydration, sn.instrumentName, sn.config),
359
+ }));
401
360
  }
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
2
2
  import { createLabStore } from './store';
3
3
 
4
4
  function store() {
5
- return createLabStore({ storageKey: 'test', storage: { read: () => null, write: () => {} } });
5
+ return createLabStore();
6
6
  }
7
7
 
8
8
  const record = { id: 't1', instrumentName: 'X', config: {}, state: {}, view: {} };
@@ -1,4 +1,5 @@
1
1
  import type { ConfigPath, ValueAtPath } from '../config/types';
2
+ import type { InstrumentList } from '../instrument/types';
2
3
  import type { UndockedPanels } from './undock';
3
4
  /** A trial's undo history, as snapshots of its state either side of the
4
5
  * present. */
@@ -79,15 +80,29 @@ export interface LabStoreState {
79
80
  /** Sidebar sections torn out of their trial. A panel here is not rendered in
80
81
  * its trial's sidebar; the workspace renders it instead. */
81
82
  undockedPanels: UndockedPanels;
83
+ /** The instruments this store serializes, migrates and fills configs for.
84
+ * Null for a store built without them, whose trials look their instrument
85
+ * up on the lab instead. Not persisted. */
86
+ instruments: InstrumentList | null;
82
87
  }
83
88
 
84
- /** Where a lab persists itself. Implementations are keyed string storage and
85
- * nothing more, so the same store works against localStorage, the URL hash,
86
- * or memory. */
89
+ /** A change someone else made to one record: its new value, or `undefined`
90
+ * when it was deleted. */
91
+ export type StorageChange = [key: string, value: unknown];
92
+
93
+ /** Where a lab persists itself: asynchronous keyed storage of
94
+ * structured-clone values, so IndexedDB, the URL, memory or a server can all
95
+ * back one. */
87
96
  export interface StorageAdapter {
88
- read(key: string): string | null;
89
- write(key: string, value: string): void;
90
- delete?(key: string): void;
97
+ /** `undefined` when the key is absent. */
98
+ get(key: string): Promise<unknown>;
99
+ list(prefix: string): Promise<[string, unknown][]>;
100
+ /** Rejects when the value did not land. */
101
+ set(key: string, value: unknown): Promise<void>;
102
+ delete(key: string): Promise<void>;
103
+ /** Reports writes under `prefix` made by anyone but this adapter — another
104
+ * tab, another instance, a server. Omit when the substrate cannot tell. */
105
+ subscribe?(prefix: string, on: (changes: StorageChange[]) => void): () => void;
91
106
  }
92
107
 
93
108
  /** What `useTrialState` hands an instrument: its state and config, with
@@ -101,23 +116,32 @@ export interface TrialStateHandle<TS, TC> {
101
116
  setConfig: <P extends ConfigPath<TC> & string>(path: P, value: ValueAtPath<TC, P>) => void;
102
117
  }
103
118
 
104
- /** Options for `createLabStore`. `storageKey` namespaces the keys written, so
105
- * two labs on one origin do not collide. */
119
+ /** Options for `createLabStore`, which knows nothing about storage. */
106
120
  export interface CreateLabStoreOptions {
107
- storageKey: string;
108
- storage: StorageAdapter;
121
+ /** A document already read and migrated — what `openLabStore` hands over. */
122
+ initial?: LabDocument;
109
123
  initialMode?: LabMode;
110
124
  /** Each instrument's default config, keyed by instrument name, used to fill
111
125
  * the gaps in a stored one. A config saved before its schema grew a branch
112
126
  * arrives holding that branch's defaults rather than `undefined`, and keeps
113
- * whatever keys the schema has since stopped naming. `<Lab>` collects these
114
- * off its `instruments`. */
127
+ * whatever keys the schema has since stopped naming. */
115
128
  configDefaults?: Record<string, () => unknown>;
129
+ /** Each instrument's `migrateConfig`, keyed by instrument name, run on a
130
+ * stored config before its defaults fill it. */
131
+ configMigrations?: Record<string, (stored: unknown) => unknown>;
116
132
  /** How each instrument's state survives a reload. Hydration is the first
117
- * thing `createLabStore` does, so these have to arrive with the store —
118
- * anything registered afterwards is already too late to read the document
119
- * it was built from. `<Lab>` collects them off its `instruments`. */
133
+ * thing `createLabStore` does, so these have to arrive with the store. */
120
134
  serializers?: InstrumentSerializers;
135
+ /** The instruments to serialize, migrate and fill configs for. Given, it
136
+ * supplies all three and the three fields above are ignored. */
137
+ instruments?: InstrumentList;
138
+ }
139
+
140
+ /** What a store does per instrument on the way in and out. */
141
+ export interface InstrumentHooks {
142
+ serializers: InstrumentSerializers;
143
+ configDefaults: Record<string, () => unknown>;
144
+ configMigrations: Record<string, (stored: unknown) => unknown>;
121
145
  }
122
146
 
123
147
  /** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
@@ -0,0 +1,68 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ interface Closable {
4
+ close(): Promise<void> | void;
5
+ }
6
+
7
+ interface Entry<T> {
8
+ value: T | null;
9
+ pending: Promise<T> | null;
10
+ mounts: number;
11
+ }
12
+
13
+ /**
14
+ * Open something once for a component's lifetime: at once when `open` returns
15
+ * a value, after it settles when it returns a promise. Closed on unmount — but
16
+ * the close waits a microtask, and the remount React StrictMode performs right
17
+ * after its rehearsal unmount cancels it, so StrictMode opens it once.
18
+ */
19
+ export function useOpenOnce<T extends Closable>(open: () => T | Promise<T>): T | null {
20
+ const entry = useRef<Entry<T> | null>(null);
21
+ if (entry.current === null) {
22
+ const result = open();
23
+ entry.current =
24
+ result instanceof Promise
25
+ ? { value: null, pending: result, mounts: 0 }
26
+ : { value: result, pending: null, mounts: 0 };
27
+ }
28
+ const [value, setValue] = useState<T | null>(entry.current.value);
29
+
30
+ useEffect(() => {
31
+ const e = entry.current as Entry<T>;
32
+ e.mounts += 1;
33
+ let live = true;
34
+ void e.pending?.then((opened) => {
35
+ e.value = opened;
36
+ if (live) setValue(opened);
37
+ });
38
+ return () => {
39
+ live = false;
40
+ e.mounts -= 1;
41
+ queueMicrotask(() => {
42
+ if (e.mounts > 0) return;
43
+ if (e.value) void e.value.close();
44
+ else
45
+ void e.pending?.then((opened) => {
46
+ if (e.mounts === 0) void opened.close();
47
+ });
48
+ });
49
+ };
50
+ }, []);
51
+
52
+ return value;
53
+ }
54
+
55
+ /** Warn once, in development, when a prop read only at mount changes after it. */
56
+ export function useWarnIgnoredChange(component: string, props: Record<string, unknown>): void {
57
+ const first = useRef(props);
58
+ const warned = useRef(false);
59
+ if (process.env.NODE_ENV === 'production' || warned.current) return;
60
+ for (const key of Object.keys(props)) {
61
+ if (Object.is(first.current[key], props[key])) continue;
62
+ warned.current = true;
63
+ console.warn(
64
+ `[labkit] ${component} reads \`${key}\` once, at mount; a later change is ignored`,
65
+ );
66
+ return;
67
+ }
68
+ }