@weasel-js/labkit 1.4.4 → 1.5.1

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 (278) hide show
  1. package/README.md +54 -6
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
  7. package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
  8. package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
  9. package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
  10. package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
  11. package/dist/canvas/index.d.ts +18 -129
  12. package/dist/canvas/index.js +4 -2
  13. package/dist/chrome/index.d.ts +5 -93
  14. package/dist/chrome/index.js +10 -6
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  26. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  27. package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
  28. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  29. package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
  30. package/dist/chunk-IJJUK4FU.js.map +1 -0
  31. package/dist/chunk-IXTQI7JX.js +737 -0
  32. package/dist/chunk-IXTQI7JX.js.map +1 -0
  33. package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
  34. package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
  35. package/dist/chunk-PHK4W562.js +72 -0
  36. package/dist/chunk-PHK4W562.js.map +1 -0
  37. package/dist/chunk-PJ7WEQBJ.js +18 -0
  38. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  39. package/dist/chunk-RWNDDKOH.js +682 -0
  40. package/dist/chunk-RWNDDKOH.js.map +1 -0
  41. package/dist/chunk-T3EC2DXD.js +12628 -0
  42. package/dist/chunk-T3EC2DXD.js.map +1 -0
  43. package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
  44. package/dist/chunk-UN4U4P63.js.map +1 -0
  45. package/dist/chunk-WIG6XHJ7.js +35 -0
  46. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  47. package/dist/chunk-Z76KEZAX.js +15 -0
  48. package/dist/chunk-Z76KEZAX.js.map +1 -0
  49. package/dist/chunk-ZDHY47EV.js +363 -0
  50. package/dist/chunk-ZDHY47EV.js.map +1 -0
  51. package/dist/config/index.d.ts +276 -0
  52. package/dist/config/index.js +8 -0
  53. package/dist/config/index.js.map +1 -0
  54. package/dist/controls/index.d.ts +3 -4
  55. package/dist/controls/index.js +4 -3
  56. package/dist/dragdrop/index.d.ts +3 -5
  57. package/dist/index.d.ts +266 -413
  58. package/dist/index.js +1775 -2251
  59. package/dist/index.js.map +1 -1
  60. package/dist/layers/index.d.ts +4 -6
  61. package/dist/layers/index.js +2 -2
  62. package/dist/loupe/index.d.ts +69 -20
  63. package/dist/loupe/index.js +2 -2
  64. package/dist/passthrough/weasel-ui.d.ts +175 -309
  65. package/dist/passthrough/weasel-ui.js +1 -1
  66. package/dist/primitives/index.d.ts +55 -6
  67. package/dist/primitives/index.js +6 -4
  68. package/dist/state/index.d.ts +13 -8
  69. package/dist/state/index.js +18 -13
  70. package/dist/state/index.js.map +1 -1
  71. package/dist/styles.css +223 -76
  72. package/dist/surface/index.d.ts +44 -7
  73. package/dist/surface/index.js +2 -1
  74. package/dist/ui/layers/index.js +1 -1
  75. package/dist/undo/index.d.ts +3 -5
  76. package/package.json +15 -8
  77. package/src/annotations/AnnotationOverlay.tsx +56 -5
  78. package/src/annotations/Annotations.less +4 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +105 -6
  80. package/src/annotations/drawOne.test.ts +1 -1
  81. package/src/annotations/drawOne.ts +2 -2
  82. package/src/annotations/paint.test.ts +2 -2
  83. package/src/annotations/paint.ts +1 -1
  84. package/src/annotations/preload.ts +6 -0
  85. package/src/annotations/store.test.ts +18 -0
  86. package/src/annotations/store.ts +5 -4
  87. package/src/annotations/svgNodes.test.ts +2 -2
  88. package/src/annotations/svgNodes.ts +1 -0
  89. package/src/annotations/toolMap.test.ts +9 -5
  90. package/src/annotations/toolMap.ts +6 -3
  91. package/src/annotations/types.ts +6 -3
  92. package/src/canvas/CameraWheelContext.ts +12 -0
  93. package/src/canvas/CanvasStack.stories.tsx +1 -1
  94. package/src/canvas/CanvasStack.test.tsx +25 -0
  95. package/src/canvas/CanvasStack.tsx +31 -4
  96. package/src/canvas/Stage.less +18 -0
  97. package/src/canvas/Stage.test.tsx +186 -0
  98. package/src/canvas/Stage.tsx +134 -0
  99. package/src/canvas/camera.test.ts +17 -2
  100. package/src/canvas/camera.ts +9 -9
  101. package/src/canvas/index.ts +4 -0
  102. package/src/canvas/usePanZoom.test.ts +28 -0
  103. package/src/canvas/usePanZoom.ts +21 -9
  104. package/src/chrome/ChromeRegions.stories.tsx +1 -4
  105. package/src/chrome/LabChrome.tsx +130 -0
  106. package/src/chrome/builtins.test.ts +1 -0
  107. package/src/chrome/builtins.tsx +21 -31
  108. package/src/chrome/index.ts +16 -0
  109. package/src/chrome/labRegions.test.tsx +208 -0
  110. package/src/chrome/labTypes.ts +50 -0
  111. package/src/chrome/merge.ts +7 -9
  112. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  113. package/src/chrome/regions/PaletteRegion.tsx +16 -9
  114. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  115. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  116. package/src/chrome/regions/StatusRegion.tsx +15 -8
  117. package/src/chrome/regions/ToolbarRegion.tsx +34 -16
  118. package/src/chrome/types.ts +50 -20
  119. package/src/config/auto.test.ts +12 -0
  120. package/src/config/auto.ts +15 -0
  121. package/src/config/autoConfig.test.ts +107 -0
  122. package/src/config/autoConfig.ts +153 -0
  123. package/src/config/builder.test.ts +42 -0
  124. package/src/config/builder.ts +68 -17
  125. package/src/config/declarationEmit.fixture.ts +11 -0
  126. package/src/config/declarationEmit.test.ts +51 -0
  127. package/src/config/entry.test.ts +46 -0
  128. package/src/config/index.ts +16 -1
  129. package/src/config/nodeClasses.test.ts +18 -0
  130. package/src/config/path.ts +7 -2
  131. package/src/config/resolve.test.ts +25 -0
  132. package/src/config/resolve.ts +17 -1
  133. package/src/config/types.ts +34 -2
  134. package/src/config/useResolvedConfig.ts +26 -0
  135. package/src/controls/AutoControls.stories.tsx +88 -0
  136. package/src/controls/ControlPanel.stories.tsx +42 -1
  137. package/src/controls/ControlPanel.test.tsx +530 -4
  138. package/src/controls/ControlPanel.tsx +562 -82
  139. package/src/fake-indexeddb-auto.d.ts +5 -0
  140. package/src/index.test.ts +7 -0
  141. package/src/index.ts +66 -5
  142. package/src/instrument/serializers.test.ts +22 -0
  143. package/src/instrument/serializers.ts +40 -1
  144. package/src/instrument/types.ts +25 -1
  145. package/src/lab/Lab.chrome.test.tsx +16 -0
  146. package/src/lab/Lab.less +4 -13
  147. package/src/lab/Lab.persist.test.tsx +147 -0
  148. package/src/lab/Lab.stories.tsx +1 -3
  149. package/src/lab/Lab.surface.test.tsx +71 -3
  150. package/src/lab/Lab.test.tsx +168 -16
  151. package/src/lab/Lab.tsx +434 -129
  152. package/src/lab/LabContext.ts +8 -0
  153. package/src/lab/LabFit.stories.less +18 -0
  154. package/src/lab/LabFit.stories.tsx +219 -0
  155. package/src/lab/LabFullChrome.stories.tsx +1 -4
  156. package/src/lab/LabHeader.test.tsx +40 -9
  157. package/src/lab/LabHeader.tsx +7 -14
  158. package/src/lab/LabPalette.tsx +13 -26
  159. package/src/lab/LabRoot.test.tsx +56 -0
  160. package/src/lab/LabRoot.tsx +47 -0
  161. package/src/lab/LabShell.less +73 -15
  162. package/src/lab/LabShell.stories.tsx +50 -1
  163. package/src/lab/LabShell.tsx +18 -27
  164. package/src/lab/LabSwitcher.less +18 -11
  165. package/src/lab/LabSwitcher.test.tsx +14 -9
  166. package/src/lab/LabSwitcher.tsx +8 -4
  167. package/src/lab/Workspace.less +1 -1
  168. package/src/lab/Workspace.stories.tsx +1 -1
  169. package/src/lab/Workspace.surface.test.tsx +1 -0
  170. package/src/lab/fitCheck.test.ts +57 -0
  171. package/src/lab/fitCheck.ts +125 -0
  172. package/src/lab/index.ts +2 -0
  173. package/src/layers/LayerList.stories.tsx +1 -1
  174. package/src/loupe/AGENTS.md +17 -5
  175. package/src/loupe/Loupe.less +1 -1
  176. package/src/loupe/LoupeGestures.tsx +67 -0
  177. package/src/loupe/TrialLoupe.tsx +43 -28
  178. package/src/loupe/canvasLens.ts +1 -1
  179. package/src/loupe/index.ts +4 -0
  180. package/src/loupe/loupeActions.ts +98 -0
  181. package/src/loupe/types.ts +1 -1
  182. package/src/loupe/useLoupe.test.tsx +60 -3
  183. package/src/loupe/useLoupe.ts +29 -49
  184. package/src/passthrough/weasel-ui.ts +9 -0
  185. package/src/primitives/FloatingPanel.stories.tsx +14 -2
  186. package/src/primitives/FloatingPanel.test.tsx +47 -16
  187. package/src/primitives/FloatingPanel.tsx +11 -25
  188. package/src/primitives/FpsMeter.stories.tsx +1 -1
  189. package/src/primitives/JobProgress.stories.tsx +1 -1
  190. package/src/primitives/Legend.stories.tsx +1 -1
  191. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  192. package/src/primitives/Sidebar.less +7 -0
  193. package/src/primitives/Sidebar.stories.tsx +1 -1
  194. package/src/primitives/Split.less +14 -0
  195. package/src/primitives/Split.test.tsx +133 -0
  196. package/src/primitives/Split.tsx +175 -0
  197. package/src/primitives/StatusBar.stories.tsx +1 -1
  198. package/src/primitives/Toolbar.stories.tsx +1 -1
  199. package/src/primitives/ZoomControl.tsx +5 -2
  200. package/src/primitives/index.ts +2 -0
  201. package/src/specimen/Specimen.less +95 -0
  202. package/src/specimen/Specimen.stories.tsx +13 -0
  203. package/src/specimen/Specimen.test.tsx +13 -0
  204. package/src/specimen/Specimen.tsx +888 -0
  205. package/src/state/Persistence.stories.tsx +75 -0
  206. package/src/state/Persistence.tsx +33 -0
  207. package/src/state/SingletonExperiment.test.tsx +47 -53
  208. package/src/state/SingletonExperiment.tsx +24 -13
  209. package/src/state/adapterContract.ts +116 -0
  210. package/src/state/adapters.test.ts +113 -61
  211. package/src/state/adapters.ts +328 -69
  212. package/src/state/document.test.ts +71 -96
  213. package/src/state/document.ts +66 -52
  214. package/src/state/helpers.test.ts +1 -1
  215. package/src/state/index.ts +8 -0
  216. package/src/state/labRecords.test.ts +105 -0
  217. package/src/state/labRecords.ts +160 -0
  218. package/src/state/openLabStore.test.ts +658 -0
  219. package/src/state/openLabStore.ts +403 -0
  220. package/src/state/records.test.ts +234 -0
  221. package/src/state/records.ts +219 -0
  222. package/src/state/store.test.ts +169 -635
  223. package/src/state/store.ts +146 -163
  224. package/src/state/toolSlot.test.ts +1 -1
  225. package/src/state/types.ts +65 -17
  226. package/src/state/useOpenOnce.ts +68 -0
  227. package/src/state/usePersistedState.test.tsx +125 -0
  228. package/src/state/usePersistedState.ts +66 -0
  229. package/src/state/useTrialState.test.tsx +87 -3
  230. package/src/state/useTrialState.ts +9 -1
  231. package/src/state/view.test.ts +49 -15
  232. package/src/state/view.ts +18 -1
  233. package/src/styles.less +3 -0
  234. package/src/surface/AGENTS.md +39 -7
  235. package/src/surface/SurfaceContext.ts +26 -3
  236. package/src/surface/index.ts +2 -0
  237. package/src/surface/useSurfaceTile.test.tsx +1 -0
  238. package/src/surface/useSurfaceTile.ts +6 -5
  239. package/src/surface/useTiledSurface.test.tsx +231 -1
  240. package/src/surface/useTiledSurface.ts +76 -15
  241. package/src/theme/Interstellar.stories.tsx +9 -9
  242. package/src/theme/interstellar.test.ts +17 -6
  243. package/src/theme/interstellar.theme.json +180 -0
  244. package/src/theme/interstellar.ts +8 -5
  245. package/src/tools/labTool.ts +11 -0
  246. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  247. package/src/trial/Trial.annotations.test.tsx +59 -7
  248. package/src/trial/Trial.canvas.test.tsx +32 -0
  249. package/src/trial/Trial.config.test.tsx +75 -2
  250. package/src/trial/Trial.job.test.tsx +3 -3
  251. package/src/trial/Trial.less +5 -26
  252. package/src/trial/Trial.stories.tsx +5 -3
  253. package/src/trial/Trial.test.tsx +31 -4
  254. package/src/trial/Trial.trialId.test.tsx +23 -1
  255. package/src/trial/Trial.tsx +139 -52
  256. package/src/trial/TrialBody.tsx +17 -141
  257. package/src/trial/TrialChrome.tsx +24 -7
  258. package/src/trial/trialOps.test.ts +101 -1
  259. package/src/trial/trialOps.ts +67 -7
  260. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  261. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  262. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  263. package/dist/chunk-2P6PP5N4.js +0 -553
  264. package/dist/chunk-2P6PP5N4.js.map +0 -1
  265. package/dist/chunk-64ZCN3DA.js.map +0 -1
  266. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  267. package/dist/chunk-E44SU6XS.js +0 -50
  268. package/dist/chunk-E44SU6XS.js.map +0 -1
  269. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  270. package/dist/chunk-I6JCVE24.js.map +0 -1
  271. package/dist/chunk-MOM3GOVY.js.map +0 -1
  272. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  273. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  274. package/dist/chunk-U3IYHIAE.js +0 -343
  275. package/dist/chunk-U3IYHIAE.js.map +0 -1
  276. package/dist/chunk-W3ECWC2K.js +0 -7335
  277. package/dist/chunk-W3ECWC2K.js.map +0 -1
  278. package/src/theme/interstellar.tokens.json +0 -61
@@ -1,23 +1,23 @@
1
1
  import { createStore, type StoreApi } from 'zustand/vanilla';
2
- import { fillConfigDefaults, withValueAtPath } from '../config/path';
2
+ import { applyConfigWrite } from '../config/autoConfig';
3
+ import { fillConfigDefaults } from '../config/path';
3
4
  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, newId, serializeTrials } from './helpers';
5
+ configDefaultsOf,
6
+ configMigrationsOf,
7
+ configSchemasOf,
8
+ serializersOf,
9
+ } from '../instrument/serializers';
10
+ import type { InstrumentList } from '../instrument/types';
11
+ import { emptyDocument } from './document';
12
+ import { deserializeTrials, emptyUndoStack, newId } from './helpers';
15
13
  import type {
16
14
  CreateLabStoreOptions,
17
- LabDocument,
15
+ InstrumentHooks,
16
+ InstrumentSerializers,
18
17
  LabMode,
19
18
  LabStoreState,
20
19
  SavedSnapshot,
20
+ SerializedTrial,
21
21
  TrialRecord,
22
22
  } from './types';
23
23
  import {
@@ -59,54 +59,45 @@ export interface LabStoreActions {
59
59
  undockPanel: (trialId: string, sectionId: string, as?: UndockedPanel['as']) => void;
60
60
  /** Put one section back, or — with no `sectionId` — every panel the trial owns. */
61
61
  dockPanel: (trialId: string, sectionId?: string) => void;
62
+ /** Replace the lab's instruments. Trials and saves of an instrument that is
63
+ * new or a different object are brought up to date against it in the same
64
+ * write; every other trial keeps its object. */
65
+ setInstruments: (instruments: InstrumentList) => void;
66
+ /** The per-instrument hooks in force now. Read them at use; never keep them. */
67
+ instrumentHooks: () => InstrumentHooks;
62
68
  }
63
69
 
64
70
  /** A lab's store: its state and its actions. How each instrument's state is
65
- * serialized comes in through `CreateLabStoreOptions.serializers`. */
71
+ * serialized comes in through `CreateLabStoreOptions.instruments`. */
66
72
  export type LabStore = StoreApi<LabStoreState & LabStoreActions>;
67
73
 
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;
74
+ /** Build a lab store holding `initial`, or an empty lab. It knows nothing
75
+ * about storage; `openLabStore` reads a stored lab and binds one of these to
76
+ * it. */
77
+ export function createLabStore(options: CreateLabStoreOptions = {}): LabStore {
78
+ let hooks: InstrumentHooks = options.instruments
79
+ ? hooksOf(options.instruments)
80
+ : {
81
+ serializers: options.serializers ?? {},
82
+ configDefaults: options.configDefaults ?? {},
83
+ configMigrations: options.configMigrations ?? {},
84
+ configSchemas: options.configSchemas ?? {},
85
+ };
86
+ const initial = options.initial ?? emptyDocument(options.initialMode ?? 'auto');
96
87
 
97
88
  const store = createStore<LabStoreState & LabStoreActions>()((set, get) => ({
98
- trials: hydratedTrials,
99
- savedSnapshots: hydratedSnapshots,
100
- mode: hydratedMode,
101
- layout: hydratedLayout,
102
- undockedPanels: hydrated.undockedPanels,
89
+ trials: hydrateTrials(initial.trials, hooks.serializers, hooks),
90
+ savedSnapshots: hydrateSnapshots(initial.saves, hooks),
91
+ mode: initial.mode,
92
+ layout: initial.layout,
93
+ undockedPanels: initial.undockedPanels,
103
94
  activeToolId: null,
95
+ instruments: options.instruments ?? null,
104
96
 
105
97
  addTrial: (record) => {
106
98
  set((s) => ({
107
99
  trials: [...s.trials, { ...record, undoStack: emptyUndoStack() }],
108
100
  }));
109
- scheduleFlush();
110
101
  },
111
102
 
112
103
  removeTrial: (id) => {
@@ -114,7 +105,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
114
105
  trials: s.trials.filter((w) => w.id !== id),
115
106
  undockedPanels: dockPanelIn(s.undockedPanels, id),
116
107
  }));
117
- scheduleFlush();
118
108
  },
119
109
 
120
110
  updateTrialState: (id, next) => {
@@ -130,23 +120,29 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
130
120
  return { ...w, state: nextState };
131
121
  }),
132
122
  }));
133
- scheduleFlush();
134
123
  },
135
124
 
136
125
  updateTrialConfig: (id, path, value) => {
137
126
  set((s) => ({
138
- trials: s.trials.map((w) =>
139
- w.id === id ? { ...w, config: withValueAtPath(w.config, path, value) } : w,
140
- ),
127
+ trials: s.trials.map((w) => {
128
+ if (w.id !== id) return w;
129
+ const was = w.auto ?? [];
130
+ // The sentinel is never stored: it becomes membership in `auto`, and
131
+ // the pinned value at the path is left exactly where it is.
132
+ const next = applyConfigWrite(w.config, was, path, value);
133
+ if (next.config === w.config && next.autoPaths === was) return w;
134
+ const { auto: _was, ...kept } = w;
135
+ const record: TrialRecord = { ...kept, config: next.config };
136
+ if (next.autoPaths.length > 0) record.auto = next.autoPaths;
137
+ return record;
138
+ }),
141
139
  }));
142
- scheduleFlush();
143
140
  },
144
141
 
145
142
  updateTrialView: (id, view) => {
146
143
  set((s) => ({
147
144
  trials: s.trials.map((w) => (w.id === id && !Object.is(view, w.view) ? { ...w, view } : w)),
148
145
  }));
149
- scheduleFlush();
150
146
  },
151
147
 
152
148
  updateTrialSidebarWidth: (id, width) => {
@@ -155,7 +151,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
155
151
  w.id === id && w.sidebarWidth !== width ? { ...w, sidebarWidth: width } : w,
156
152
  ),
157
153
  }));
158
- scheduleFlush();
159
154
  },
160
155
 
161
156
  setTrialTitle: (id, title) => {
@@ -167,7 +162,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
167
162
  return { ...w, title: next };
168
163
  }),
169
164
  }));
170
- scheduleFlush();
171
165
  },
172
166
 
173
167
  setTrialSectionCollapsed: (id, key, collapsed) => {
@@ -178,7 +172,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
178
172
  return { ...w, collapsedSections: { ...w.collapsedSections, [key]: collapsed } };
179
173
  }),
180
174
  }));
181
- scheduleFlush();
182
175
  },
183
176
 
184
177
  updateTrialAnnotations: (id, doc) => {
@@ -187,7 +180,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
187
180
  w.id === id && !Object.is(doc, w.annotations) ? { ...w, annotations: doc } : w,
188
181
  ),
189
182
  }));
190
- scheduleFlush();
191
183
  },
192
184
 
193
185
  updateTrialUndoStack: (id, next) => {
@@ -207,13 +199,12 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
207
199
  set((s) => ({
208
200
  trials: s.trials.map((w) => (w.id === id ? { ...w, instrumentName } : w)),
209
201
  }));
210
- scheduleFlush();
211
202
  },
212
203
 
213
204
  saveSnapshot: (trialId, name) => {
214
205
  const trial = get().trials.find((w) => w.id === trialId);
215
206
  if (!trial) return;
216
- const reg = serializers[trial.instrumentName];
207
+ const reg = hooks.serializers[trial.instrumentName];
217
208
  const serializedState = reg?.serialize
218
209
  ? reg.serialize(trial.state)
219
210
  : structuredClone(trial.state);
@@ -227,10 +218,10 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
227
218
  instrumentName: trial.instrumentName,
228
219
  config: clonedConfig,
229
220
  state: serializedState,
221
+ auto: [...(trial.auto ?? [])],
230
222
  savedAt,
231
223
  };
232
224
  set((s) => ({ savedSnapshots: [...s.savedSnapshots, snapshot] }));
233
- scheduleFlush();
234
225
  },
235
226
 
236
227
  loadSnapshot: (snapshotId, trialId) => {
@@ -244,23 +235,26 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
244
235
  );
245
236
  return;
246
237
  }
247
- const reg = serializers[snapshot.instrumentName];
238
+ const reg = hooks.serializers[snapshot.instrumentName];
248
239
  const restoredState = reg?.deserialize
249
240
  ? reg.deserialize(snapshot.state, snapshot.config)
250
241
  : snapshot.state;
251
242
  set((s) => ({
252
- trials: s.trials.map((w) =>
253
- w.id === trialId ? { ...w, state: restoredState, config: snapshot.config } : w,
254
- ),
243
+ trials: s.trials.map((w) => {
244
+ if (w.id !== trialId) return w;
245
+ const loaded: TrialRecord = { ...w, state: restoredState, config: snapshot.config };
246
+ if (snapshot.auto === undefined) return loaded;
247
+ delete loaded.auto;
248
+ if (snapshot.auto.length > 0) loaded.auto = [...snapshot.auto];
249
+ return loaded;
250
+ }),
255
251
  }));
256
- scheduleFlush();
257
252
  },
258
253
 
259
254
  deleteSnapshot: (snapshotId) => {
260
255
  set((s) => ({
261
256
  savedSnapshots: s.savedSnapshots.filter((sn) => sn.id !== snapshotId),
262
257
  }));
263
- scheduleFlush();
264
258
  },
265
259
 
266
260
  listSnapshots: (trialId) => {
@@ -271,7 +265,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
271
265
 
272
266
  setMode: (mode) => {
273
267
  set({ mode });
274
- scheduleFlush();
275
268
  },
276
269
 
277
270
  setLabTool: (id) => set({ activeToolId: id }),
@@ -283,119 +276,109 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
283
276
 
284
277
  undockPanel: (trialId, sectionId, as) => {
285
278
  set((s) => ({ undockedPanels: undockPanelIn(s.undockedPanels, trialId, sectionId, as) }));
286
- scheduleFlush();
287
279
  },
288
280
 
289
281
  dockPanel: (trialId, sectionId) => {
290
282
  set((s) => ({ undockedPanels: dockPanelIn(s.undockedPanels, trialId, sectionId) }));
291
- scheduleFlush();
292
283
  },
293
284
 
294
285
  setLayout: (layout) => {
295
286
  set({ layout });
296
- scheduleFlush();
297
287
  },
298
- }));
299
288
 
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;
289
+ setInstruments: (instruments) => {
290
+ const before = get().instruments;
291
+ if (before === instruments) return;
292
+ const next = mergedHooks(hooks, instruments);
293
+ const previous = new Map((before ?? []).map((i) => [i.name, i]));
294
+ const changed = new Set(
295
+ instruments.filter((i) => previous.get(i.name) !== i).map((i) => i.name),
296
+ );
297
+ if (changed.size === 0) {
298
+ hooks = next;
299
+ set({ instruments });
300
+ return;
317
301
  }
318
- flushTimer = null;
319
- }, 300);
320
- }
302
+ const s = get();
303
+ const trials = s.trials.map((w) =>
304
+ changed.has(w.instrumentName)
305
+ ? { ...w, config: hydratedConfig(next, w.instrumentName, w.config) }
306
+ : w,
307
+ );
308
+ const savedSnapshots = s.savedSnapshots.map((sn) =>
309
+ changed.has(sn.instrumentName)
310
+ ? { ...sn, config: hydratedConfig(next, sn.instrumentName, sn.config) }
311
+ : sn,
312
+ );
313
+ hooks = next;
314
+ set({ instruments, trials, savedSnapshots });
315
+ },
321
316
 
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();
317
+ instrumentHooks: () => hooks,
318
+ }));
325
319
 
326
320
  return store;
327
321
  }
328
322
 
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;
323
+ function hooksOf(instruments: InstrumentList): InstrumentHooks {
324
+ return {
325
+ serializers: serializersOf(instruments),
326
+ configDefaults: configDefaultsOf(instruments),
327
+ configSchemas: configSchemasOf(instruments),
328
+ configMigrations: configMigrationsOf(instruments),
329
+ };
336
330
  }
337
331
 
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;
332
+ /** Hooks for `instruments`, keeping those of any instrument no longer listed:
333
+ * its trials stay open, and their state still has to round-trip. A listed
334
+ * name takes its hooks wholly from the new list. */
335
+ function mergedHooks(old: InstrumentHooks, instruments: InstrumentList): InstrumentHooks {
336
+ const listed = new Set(instruments.map((i) => i.name));
337
+ const fresh = hooksOf(instruments);
338
+ const unlisted = <T>(entries: Record<string, T>): Record<string, T> =>
339
+ Object.fromEntries(Object.entries(entries).filter(([name]) => !listed.has(name)));
340
+ return {
341
+ serializers: { ...unlisted(old.serializers), ...fresh.serializers },
342
+ configDefaults: { ...unlisted(old.configDefaults), ...fresh.configDefaults },
343
+ configSchemas: { ...unlisted(old.configSchemas), ...fresh.configSchemas },
344
+ configMigrations: { ...unlisted(old.configMigrations), ...fresh.configMigrations },
345
+ };
354
346
  }
355
347
 
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
- }
348
+ /** How a stored config is brought up to date on the way in. */
349
+ export type ConfigHydration = Pick<CreateLabStoreOptions, 'configDefaults' | 'configMigrations'>;
350
+
351
+ function hydratedConfig(
352
+ hydration: ConfigHydration,
353
+ instrumentName: string,
354
+ config: unknown,
355
+ ): unknown {
356
+ const migrate = hydration.configMigrations?.[instrumentName];
357
+ const moved = migrate ? migrate(config) : config;
358
+ const defaults = hydration.configDefaults?.[instrumentName];
359
+ return defaults ? fillConfigDefaults(moved, defaults()) : moved;
360
+ }
395
361
 
396
- return {
397
- document: normalizeDocument(outcome.doc, options.initialMode ?? 'auto'),
398
- persistDisabled: false,
399
- foldedFromLegacy,
400
- };
362
+ /** Rebuild stored trials: migrate each config, fill its gaps from its
363
+ * instrument's defaults, then run the deserializer against the result. */
364
+ export function hydrateTrials(
365
+ trials: SerializedTrial[],
366
+ serializers: InstrumentSerializers,
367
+ hydration: ConfigHydration = {},
368
+ ): TrialRecord[] {
369
+ return deserializeTrials(
370
+ trials.map((t) => ({ ...t, config: hydratedConfig(hydration, t.instrumentName, t.config) })),
371
+ serializers,
372
+ );
373
+ }
374
+
375
+ /** Bring each stored snapshot's config up to date the way `hydrateTrials` does a trial's. */
376
+ export function hydrateSnapshots(
377
+ saves: SavedSnapshot[],
378
+ hydration: ConfigHydration = {},
379
+ ): SavedSnapshot[] {
380
+ return saves.map((sn) => ({
381
+ ...sn,
382
+ config: hydratedConfig(hydration, sn.instrumentName, sn.config),
383
+ }));
401
384
  }
@@ -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,6 @@
1
- import type { ConfigPath, ValueAtPath } from '../config/types';
1
+ import type { Auto } from '../config/auto';
2
+ import type { ConfigPath, ResolvedConfig, ValueAtPath } from '../config/types';
3
+ import type { InstrumentList } from '../instrument/types';
2
4
  import type { UndockedPanels } from './undock';
3
5
  /** A trial's undo history, as snapshots of its state either side of the
4
6
  * present. */
@@ -30,6 +32,15 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
30
32
  * defaults. Kept so Reset restores the trial's own subject rather than the
31
33
  * bare defaults. */
32
34
  configSeed?: Partial<TC>;
35
+ /** Dotted paths the trial's seed config opened as auto. Kept beside
36
+ * `configSeed`, which cannot carry them: the sentinel is stripped on the way
37
+ * in, so Reset would otherwise have no way back to the state the trial
38
+ * opened in. Absent means none. */
39
+ autoSeed?: readonly string[];
40
+ /** Dotted paths this trial has unpinned. The value at such a path stays in
41
+ * `config` — it is what the field pins back to — but the instrument reads
42
+ * the resolver's value, or `undefined`, instead. Absent means none. */
43
+ auto?: readonly string[];
33
44
  /** This trial's own tool slot. Undefined means it reads the lab's. */
34
45
  activeToolId?: string | null;
35
46
  /** The extent the trial's sidebar was last dragged to, in pixels. Undefined
@@ -59,6 +70,10 @@ export interface SavedSnapshot {
59
70
  instrumentName: string;
60
71
  config: unknown;
61
72
  state: unknown;
73
+ /** The trial's `auto` paths when saved; empty means every field was pinned.
74
+ * Absent on a snapshot saved before snapshots carried them, which loads
75
+ * over the trial's current set rather than guessing one. */
76
+ auto?: readonly string[];
62
77
  savedAt: number;
63
78
  }
64
79
 
@@ -79,15 +94,29 @@ export interface LabStoreState {
79
94
  /** Sidebar sections torn out of their trial. A panel here is not rendered in
80
95
  * its trial's sidebar; the workspace renders it instead. */
81
96
  undockedPanels: UndockedPanels;
97
+ /** The instruments this store serializes, migrates and fills configs for.
98
+ * Null for a store built without them, whose trials look their instrument
99
+ * up on the lab instead. Not persisted. */
100
+ instruments: InstrumentList | null;
82
101
  }
83
102
 
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. */
103
+ /** A change someone else made to one record: its new value, or `undefined`
104
+ * when it was deleted. */
105
+ export type StorageChange = [key: string, value: unknown];
106
+
107
+ /** Where a lab persists itself: asynchronous keyed storage of
108
+ * structured-clone values, so IndexedDB, the URL, memory or a server can all
109
+ * back one. */
87
110
  export interface StorageAdapter {
88
- read(key: string): string | null;
89
- write(key: string, value: string): void;
90
- delete?(key: string): void;
111
+ /** `undefined` when the key is absent. */
112
+ get(key: string): Promise<unknown>;
113
+ list(prefix: string): Promise<[string, unknown][]>;
114
+ /** Rejects when the value did not land. */
115
+ set(key: string, value: unknown): Promise<void>;
116
+ delete(key: string): Promise<void>;
117
+ /** Reports writes under `prefix` made by anyone but this adapter — another
118
+ * tab, another instance, a server. Omit when the substrate cannot tell. */
119
+ subscribe?(prefix: string, on: (changes: StorageChange[]) => void): () => void;
91
120
  }
92
121
 
93
122
  /** What `useTrialState` hands an instrument: its state and config, with
@@ -96,28 +125,47 @@ export interface TrialStateHandle<TS, TC> {
96
125
  state: TS;
97
126
  setState: (next: TS | ((prev: TS) => TS)) => void;
98
127
  config: TC;
128
+ /** The config as stored, before any auto path is resolved. What the control
129
+ * panel renders, so a ghosted control sits at the value it pins back to. */
130
+ raw: TC;
131
+ /** Dotted paths this trial has unpinned. */
132
+ auto: ReadonlySet<string>;
99
133
  /** Write one config value, by dotted path — `'grid.size'` for a leaf under
100
134
  * an `f.group`, `'cellSize'` for one at the root. */
101
- setConfig: <P extends ConfigPath<TC> & string>(path: P, value: ValueAtPath<TC, P>) => void;
135
+ setConfig: <P extends ConfigPath<TC> & string>(path: P, value: ValueAtPath<TC, P> | Auto) => void;
102
136
  }
103
137
 
104
- /** Options for `createLabStore`. `storageKey` namespaces the keys written, so
105
- * two labs on one origin do not collide. */
138
+ /** Options for `createLabStore`, which knows nothing about storage. */
106
139
  export interface CreateLabStoreOptions {
107
- storageKey: string;
108
- storage: StorageAdapter;
140
+ /** A document already read and migrated — what `openLabStore` hands over. */
141
+ initial?: LabDocument;
109
142
  initialMode?: LabMode;
110
143
  /** Each instrument's default config, keyed by instrument name, used to fill
111
144
  * the gaps in a stored one. A config saved before its schema grew a branch
112
145
  * 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`. */
146
+ * whatever keys the schema has since stopped naming. */
115
147
  configDefaults?: Record<string, () => unknown>;
148
+ /** Each instrument's `migrateConfig`, keyed by instrument name, run on a
149
+ * stored config before its defaults fill it. */
150
+ configMigrations?: Record<string, (stored: unknown) => unknown>;
151
+ /** Each instrument's resolved config schema, keyed by instrument name. What
152
+ * a trial's auto paths are read against, away from the lab that would
153
+ * otherwise supply the rules. */
154
+ configSchemas?: Record<string, ResolvedConfig>;
116
155
  /** 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`. */
156
+ * thing `createLabStore` does, so these have to arrive with the store. */
120
157
  serializers?: InstrumentSerializers;
158
+ /** The instruments to serialize, migrate and fill configs for. Given, it
159
+ * supplies all three and the three fields above are ignored. */
160
+ instruments?: InstrumentList;
161
+ }
162
+
163
+ /** What a store does per instrument on the way in and out. */
164
+ export interface InstrumentHooks {
165
+ serializers: InstrumentSerializers;
166
+ configDefaults: Record<string, () => unknown>;
167
+ configMigrations: Record<string, (stored: unknown) => unknown>;
168
+ configSchemas: Record<string, ResolvedConfig>;
121
169
  }
122
170
 
123
171
  /** 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
+ }