@weasel-js/labkit 1.4.1 → 1.4.3

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 (145) hide show
  1. package/README.md +28 -2
  2. package/dist/_dts/{CanvasStackContext-BIRUVVGH.d.ts → CanvasStackContext-Dde8_1my.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-CgxUequc.d.ts → PrefsForm.d-DDHFANkc.d.ts} +1 -1
  4. package/dist/_dts/{frac-X7mWgd7y.d.ts → frac-B5mB8LsJ.d.ts} +33 -13
  5. package/dist/_dts/{index-FEa1MudK.d.ts → index-DelFfeV0.d.ts} +99 -31
  6. package/dist/_dts/types-B9_zrHmb.d.ts +166 -0
  7. package/dist/_dts/{types-ChVJJHvk.d.ts → types-BP2OCcpg.d.ts} +41 -4
  8. package/dist/_dts/types-x92Kfeme.d.ts +62 -0
  9. package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-D6Vb3T-g.d.ts} +11 -7
  10. package/dist/canvas/index.d.ts +9 -8
  11. package/dist/canvas/index.js +1 -2
  12. package/dist/chrome/index.d.ts +16 -9
  13. package/dist/chrome/index.js +5 -5
  14. package/dist/{chunk-MSR7EVS6.js → chunk-2RQPGOQF.js} +6 -5
  15. package/dist/chunk-2RQPGOQF.js.map +1 -0
  16. package/dist/{chunk-LJSIUFYD.js → chunk-5TROQVQ2.js} +46 -21
  17. package/dist/chunk-5TROQVQ2.js.map +1 -0
  18. package/dist/{chunk-53AZ2O2N.js → chunk-ASWKLRKJ.js} +92 -45
  19. package/dist/chunk-ASWKLRKJ.js.map +1 -0
  20. package/dist/{chunk-BMQWLKOL.js → chunk-C42T6DJT.js} +94 -83
  21. package/dist/chunk-C42T6DJT.js.map +1 -0
  22. package/dist/chunk-E44SU6XS.js +50 -0
  23. package/dist/chunk-E44SU6XS.js.map +1 -0
  24. package/dist/{chunk-OFCBEZMU.js → chunk-FQMJUVHJ.js} +3 -3
  25. package/dist/{chunk-OFCBEZMU.js.map → chunk-FQMJUVHJ.js.map} +1 -1
  26. package/dist/{chunk-PP7ZWAMV.js → chunk-I6JCVE24.js} +3 -3
  27. package/dist/chunk-I6JCVE24.js.map +1 -0
  28. package/dist/chunk-ISCOFAUO.js +170 -0
  29. package/dist/chunk-ISCOFAUO.js.map +1 -0
  30. package/dist/chunk-MHAGC6VD.js +7320 -0
  31. package/dist/chunk-MHAGC6VD.js.map +1 -0
  32. package/dist/{chunk-7TRFMIWX.js → chunk-QAGAHJKZ.js} +4 -4
  33. package/dist/{chunk-7TRFMIWX.js.map → chunk-QAGAHJKZ.js.map} +1 -1
  34. package/dist/{chunk-D7QIPGEB.js → chunk-RS3HRQWU.js} +62 -60
  35. package/dist/chunk-RS3HRQWU.js.map +1 -0
  36. package/dist/{chunk-RLOXQZW3.js → chunk-TJ7QY3OC.js} +3 -3
  37. package/dist/{chunk-RLOXQZW3.js.map → chunk-TJ7QY3OC.js.map} +1 -1
  38. package/dist/controls/index.d.ts +5 -4
  39. package/dist/controls/index.js +3 -3
  40. package/dist/dragdrop/index.d.ts +9 -8
  41. package/dist/dragdrop/index.js +1 -2
  42. package/dist/index.d.ts +152 -57
  43. package/dist/index.js +315 -200
  44. package/dist/index.js.map +1 -1
  45. package/dist/layers/index.d.ts +27 -11
  46. package/dist/layers/index.js +2 -3
  47. package/dist/loupe/index.d.ts +8 -7
  48. package/dist/loupe/index.js +1 -2
  49. package/dist/passthrough/weasel-canvas.d.ts +1 -3
  50. package/dist/passthrough/weasel-canvas.js +1 -1
  51. package/dist/passthrough/weasel-canvas.js.map +1 -1
  52. package/dist/passthrough/weasel-ui.d.ts +171 -22
  53. package/dist/passthrough/weasel-ui.js +1 -2
  54. package/dist/primitives/index.js +3 -4
  55. package/dist/state/index.d.ts +6 -3
  56. package/dist/state/index.js +3 -2
  57. package/dist/state/index.js.map +1 -1
  58. package/dist/styles.css +21 -4
  59. package/dist/surface/index.js +1 -2
  60. package/dist/ui/layers/index.d.ts +22 -9
  61. package/dist/ui/layers/index.js +1 -2
  62. package/dist/undo/index.d.ts +6 -6
  63. package/package.json +7 -7
  64. package/src/annotations/AnnotationTargets.tsx +6 -1
  65. package/src/annotations/Annotations.overlay.test.tsx +2 -0
  66. package/src/annotations/ExportMenu.test.tsx +16 -0
  67. package/src/annotations/ExportMenu.tsx +0 -5
  68. package/src/annotations/types.ts +4 -1
  69. package/src/canvas/useOrbit.test.ts +7 -3
  70. package/src/canvas/usePanZoom.test.ts +7 -3
  71. package/src/chrome/ChromeRegions.stories.tsx +42 -1
  72. package/src/chrome/builtins.test.ts +4 -0
  73. package/src/chrome/builtins.tsx +18 -0
  74. package/src/chrome/index.ts +1 -1
  75. package/src/chrome/regions/SidebarRegion.tsx +7 -6
  76. package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
  77. package/src/chrome/regions/TitleBarRegion.tsx +16 -7
  78. package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
  79. package/src/chrome/regions/ToolbarRegion.tsx +2 -2
  80. package/src/chrome/regions/ViewportRegion.tsx +2 -2
  81. package/src/chrome/regions/regions.test.tsx +45 -1
  82. package/src/chrome/types.ts +21 -3
  83. package/src/config/builder.test.ts +56 -2
  84. package/src/config/builder.ts +73 -13
  85. package/src/config/index.ts +16 -1
  86. package/src/config/path.test.ts +109 -0
  87. package/src/config/path.ts +76 -0
  88. package/src/config/resolve.test.ts +93 -2
  89. package/src/config/resolve.ts +72 -29
  90. package/src/config/types.ts +99 -9
  91. package/src/config/visible.ts +6 -4
  92. package/src/controls/ControlPanel.stories.tsx +38 -4
  93. package/src/controls/ControlPanel.test.tsx +152 -0
  94. package/src/controls/ControlPanel.tsx +134 -40
  95. package/src/dragdrop/DragDropRuntime.tsx +74 -61
  96. package/src/dragdrop/Palette.tsx +4 -3
  97. package/src/dragdrop/dragDrop.test.tsx +35 -12
  98. package/src/index.ts +18 -2
  99. package/src/instrument/serializers.ts +36 -0
  100. package/src/instrument/types.ts +8 -7
  101. package/src/lab/Lab.test.tsx +36 -1
  102. package/src/lab/Lab.tsx +12 -3
  103. package/src/lab/LabContext.ts +5 -1
  104. package/src/lab/LabShell.less +17 -2
  105. package/src/lab/LabShell.test.tsx +8 -0
  106. package/src/lab/LabShell.tsx +3 -1
  107. package/src/layers/AGENTS.md +29 -7
  108. package/src/layers/LayerList.less +16 -0
  109. package/src/layers/LayerList.stories.tsx +66 -0
  110. package/src/layers/LayerList.test.tsx +117 -5
  111. package/src/layers/LayerList.tsx +192 -53
  112. package/src/layers/index.ts +1 -1
  113. package/src/passthrough/weasel-ui.ts +5 -0
  114. package/src/primitives/FloatingPanel.test.tsx +8 -3
  115. package/src/primitives/ZoomControl.less +6 -2
  116. package/src/primitives/ZoomControl.tsx +1 -0
  117. package/src/state/helpers.ts +1 -1
  118. package/src/state/store.test.ts +233 -1
  119. package/src/state/store.ts +57 -24
  120. package/src/state/types.ts +44 -3
  121. package/src/state/useTrialState.ts +1 -1
  122. package/src/trial/Trial.config.test.tsx +44 -0
  123. package/src/trial/Trial.less +4 -0
  124. package/src/trial/Trial.targets.test.tsx +96 -0
  125. package/src/trial/Trial.test.tsx +171 -14
  126. package/src/trial/Trial.tsx +7 -4
  127. package/src/trial/TrialChrome.tsx +32 -7
  128. package/src/trial/TrialTitleBar.tsx +7 -3
  129. package/src/trial/index.ts +1 -0
  130. package/src/trial/trialOps.test.ts +49 -1
  131. package/src/trial/trialOps.ts +24 -6
  132. package/dist/_dts/types-lg4TSCb2.d.ts +0 -164
  133. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +0 -5041
  134. package/dist/chunk-53AZ2O2N.js.map +0 -1
  135. package/dist/chunk-BMQWLKOL.js.map +0 -1
  136. package/dist/chunk-D7QIPGEB.js.map +0 -1
  137. package/dist/chunk-DG2IFMLA.js +0 -29970
  138. package/dist/chunk-DG2IFMLA.js.map +0 -1
  139. package/dist/chunk-J6SBFZYT.js +0 -7121
  140. package/dist/chunk-J6SBFZYT.js.map +0 -1
  141. package/dist/chunk-LJSIUFYD.js.map +0 -1
  142. package/dist/chunk-MIT3ISW4.js +0 -100
  143. package/dist/chunk-MIT3ISW4.js.map +0 -1
  144. package/dist/chunk-MSR7EVS6.js.map +0 -1
  145. package/dist/chunk-PP7ZWAMV.js.map +0 -1
@@ -13,7 +13,7 @@
13
13
  }
14
14
  }
15
15
 
16
- // Both children are weasel-ui components whose module classes set `width`.
16
+ // The slider is a weasel-ui component whose module class sets `width`.
17
17
  // See the `.lk-root` prefix convention in theme/base.less.
18
18
  .lk-root {
19
19
  .lk-zoom {
@@ -24,8 +24,12 @@
24
24
  min-width: 64px;
25
25
  }
26
26
 
27
+ // `width: fit` sizes the field from this rather than from the input's
28
+ // 20-character intrinsic width. "800%" measures 33px against the 35px a
29
+ // 5ch box gives, which is no margin at all in another UI font; 6ch also
30
+ // holds the "1600%" a consumer raising `max` can reach.
27
31
  &__field {
28
- width: 62px;
32
+ --wzl-number-field-width: 6ch;
29
33
  }
30
34
  }
31
35
  }
@@ -60,6 +60,7 @@ export function ZoomControl({
60
60
  factor itself and renders 0.5 as "50%". */}
61
61
  <NumberField
62
62
  className="lk-zoom__field"
63
+ width="fit"
63
64
  aria-label="Zoom"
64
65
  value={zoom}
65
66
  minValue={min}
@@ -58,7 +58,7 @@ export function deserializeTrials(
58
58
  const d = deserializers[r.instrumentName];
59
59
  return {
60
60
  ...r,
61
- state: d?.deserialize ? d.deserialize(r.state) : r.state,
61
+ state: d?.deserialize ? d.deserialize(r.state, r.config) : r.state,
62
62
  undoStack: emptyUndoStack(),
63
63
  };
64
64
  });
@@ -100,10 +100,114 @@ describe('updateTrialConfig', () => {
100
100
  state: {},
101
101
  view: { zoom: 1, pan: { x: 0, y: 0 } },
102
102
  });
103
- s.getState().updateTrialConfig('w1', 'x' as never, 99 as never);
103
+ s.getState().updateTrialConfig('w1', 'x', 99);
104
104
  expect((s.getState().trials[0]?.config as { x: number }).x).toBe(99);
105
105
  expect((s.getState().trials[0]?.config as { y: number }).y).toBe(2);
106
106
  });
107
+
108
+ it('writes down a dotted path without disturbing its siblings', () => {
109
+ const s = makeStore();
110
+ const config = { grid: { size: 20, color: '#fff' }, showGrid: true };
111
+ s.getState().addTrial({
112
+ id: 'w1',
113
+ instrumentName: 'T',
114
+ config,
115
+ state: {},
116
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
117
+ });
118
+ s.getState().updateTrialConfig('w1', 'grid.size', 40);
119
+ expect(s.getState().trials[0]?.config).toEqual({
120
+ grid: { size: 40, color: '#fff' },
121
+ showGrid: true,
122
+ });
123
+ // The record handed in is never mutated: a trial re-renders on identity.
124
+ expect(config.grid.size).toBe(20);
125
+ });
126
+ });
127
+
128
+ describe('createLabStore - config defaults', () => {
129
+ const stored = (config: unknown) =>
130
+ JSON.stringify({
131
+ version: CURRENT_DOCUMENT_VERSION,
132
+ trials: [
133
+ {
134
+ id: 'w1',
135
+ instrumentName: 'T',
136
+ config,
137
+ state: {},
138
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
139
+ },
140
+ ],
141
+ saves: [
142
+ {
143
+ id: 's1',
144
+ name: 'saved',
145
+ trialId: 'w1',
146
+ instrumentName: 'T',
147
+ config,
148
+ state: {},
149
+ savedAt: 1,
150
+ },
151
+ ],
152
+ layout: {},
153
+ undockedPanels: {},
154
+ mode: 'auto',
155
+ });
156
+
157
+ it('fills a branch a config stored before the schema nested it never had', () => {
158
+ const storage = createMemoryAdapter();
159
+ storage.write(labDocumentKey('test'), stored({ showGrid: false, gridSize: 40 }));
160
+ const s = createLabStore({
161
+ storageKey: 'test',
162
+ storage,
163
+ configDefaults: { T: () => ({ showGrid: true, grid: { size: 20, color: '#fff' } }) },
164
+ });
165
+ expect(s.getState().trials[0]?.config).toEqual({
166
+ showGrid: false,
167
+ gridSize: 40,
168
+ grid: { size: 20, color: '#fff' },
169
+ });
170
+ });
171
+
172
+ it('fills the config on a saved snapshot the same way', () => {
173
+ const storage = createMemoryAdapter();
174
+ storage.write(labDocumentKey('test'), stored({ grid: { size: 40 } }));
175
+ const s = createLabStore({
176
+ storageKey: 'test',
177
+ storage,
178
+ configDefaults: { T: () => ({ grid: { size: 20, color: '#fff' } }) },
179
+ });
180
+ expect(s.getState().savedSnapshots[0]?.config).toEqual({
181
+ grid: { size: 40, color: '#fff' },
182
+ });
183
+ });
184
+
185
+ it('hands a deserializer the filled config, not the stored one', () => {
186
+ const storage = createMemoryAdapter();
187
+ storage.write(labDocumentKey('test'), stored({ grid: { size: 40 } }));
188
+ const seen: unknown[] = [];
189
+ createLabStore({
190
+ storageKey: 'test',
191
+ storage,
192
+ configDefaults: { T: () => ({ grid: { size: 20, color: '#fff' } }) },
193
+ serializers: {
194
+ T: {
195
+ deserialize: (state, config) => {
196
+ seen.push(config);
197
+ return state;
198
+ },
199
+ },
200
+ },
201
+ });
202
+ expect(seen).toEqual([{ grid: { size: 40, color: '#fff' } }]);
203
+ });
204
+
205
+ it('leaves a config alone when no defaults are registered for its instrument', () => {
206
+ const storage = createMemoryAdapter();
207
+ storage.write(labDocumentKey('test'), stored({ only: 1 }));
208
+ const s = createLabStore({ storageKey: 'test', storage });
209
+ expect(s.getState().trials[0]?.config).toEqual({ only: 1 });
210
+ });
107
211
  });
108
212
 
109
213
  describe('setMode', () => {
@@ -222,6 +326,56 @@ describe('persistence — hydration', () => {
222
326
  expect(hydrated.getState().trials).toHaveLength(1);
223
327
  expect((hydrated.getState().trials[0]?.state as { n: number }).n).toBe(7);
224
328
  });
329
+
330
+ // An instrument holding a Map, a Set or anything else JSON drops needs these
331
+ // to run at both ends. They are read while the store is being built, which
332
+ // is why they arrive as an option rather than being registered afterwards.
333
+ it("runs an instrument's serializers at flush and at hydrate", () => {
334
+ vi.useFakeTimers();
335
+ const mem = createMemoryAdapter();
336
+ const serializers = {
337
+ T: {
338
+ serialize: (state: unknown) => [...(state as Map<string, number>)],
339
+ deserialize: (data: unknown) => new Map(data as [string, number][]),
340
+ },
341
+ };
342
+
343
+ const seed = createLabStore({ storageKey: 'test', storage: mem, serializers });
344
+ seed.getState().addTrial({
345
+ id: 'w1',
346
+ instrumentName: 'T',
347
+ config: {},
348
+ state: new Map([['a', 1]]),
349
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
350
+ });
351
+ vi.advanceTimersByTime(500);
352
+ vi.useRealTimers();
353
+
354
+ const written = mem.read(labDocumentKey('test')) ?? '';
355
+ expect(written).toContain('[["a",1]]');
356
+
357
+ const hydrated = createLabStore({ storageKey: 'test', storage: mem, serializers });
358
+ expect(hydrated.getState().trials[0]?.state).toEqual(new Map([['a', 1]]));
359
+ });
360
+
361
+ it('hands `deserialize` the config the state was saved against', () => {
362
+ vi.useFakeTimers();
363
+ const mem = createMemoryAdapter();
364
+ const deserialize = vi.fn((data: unknown) => data);
365
+ const seed = createLabStore({ storageKey: 'test', storage: mem });
366
+ seed.getState().addTrial({
367
+ id: 'w1',
368
+ instrumentName: 'T',
369
+ config: { scale: 4 },
370
+ state: { n: 1 },
371
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
372
+ });
373
+ vi.advanceTimersByTime(500);
374
+ vi.useRealTimers();
375
+
376
+ createLabStore({ storageKey: 'test', storage: mem, serializers: { T: { deserialize } } });
377
+ expect(deserialize).toHaveBeenCalledWith({ n: 1 }, { scale: 4 });
378
+ });
225
379
  });
226
380
 
227
381
  describe('persistence — debounced writes', () => {
@@ -673,3 +827,81 @@ describe('undocking a sidebar panel', () => {
673
827
  expect(s.getState().undockedPanels).toEqual({});
674
828
  });
675
829
  });
830
+
831
+ describe('setTrialTitle', () => {
832
+ it('holds a title and returns to the instrument name on null', () => {
833
+ const s = makeStore();
834
+ s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
835
+ s.getState().setTrialTitle('w1', 'Sprocket 7');
836
+ expect(s.getState().trials[0]?.title).toBe('Sprocket 7');
837
+ s.getState().setTrialTitle('w1', null);
838
+ expect(s.getState().trials[0]?.title).toBeUndefined();
839
+ });
840
+
841
+ it('leaves the record alone when the title is unchanged', () => {
842
+ const s = makeStore();
843
+ s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
844
+ s.getState().setTrialTitle('w1', 'Sprocket 7');
845
+ const before = s.getState().trials[0];
846
+ s.getState().setTrialTitle('w1', 'Sprocket 7');
847
+ expect(s.getState().trials[0]).toBe(before);
848
+ });
849
+
850
+ it('round-trips through storage', () => {
851
+ vi.useFakeTimers();
852
+ const storage = createMemoryAdapter();
853
+ const first = makeStore({ storage });
854
+ first.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
855
+ first.getState().setTrialTitle('w1', 'Sprocket 7');
856
+ vi.advanceTimersByTime(400);
857
+ vi.useRealTimers();
858
+
859
+ expect(makeStore({ storage }).getState().trials[0]?.title).toBe('Sprocket 7');
860
+ });
861
+ });
862
+
863
+ describe('setTrialSectionCollapsed', () => {
864
+ it('folds one section without touching the others', () => {
865
+ const s = makeStore();
866
+ s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
867
+ s.getState().setTrialSectionCollapsed('w1', 'settings', true);
868
+ s.getState().setTrialSectionCollapsed('w1', 'marks', false);
869
+ expect(s.getState().trials[0]?.collapsedSections).toEqual({ settings: true, marks: false });
870
+ });
871
+
872
+ it('leaves the record alone when the fold is unchanged', () => {
873
+ const s = makeStore();
874
+ s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
875
+ s.getState().setTrialSectionCollapsed('w1', 'settings', true);
876
+ const before = s.getState().trials[0];
877
+ s.getState().setTrialSectionCollapsed('w1', 'settings', true);
878
+ expect(s.getState().trials[0]).toBe(before);
879
+ });
880
+
881
+ it('round-trips through storage', () => {
882
+ vi.useFakeTimers();
883
+ const storage = createMemoryAdapter();
884
+ const first = makeStore({ storage });
885
+ first.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
886
+ first.getState().setTrialSectionCollapsed('w1', 'settings/Shape', true);
887
+ vi.advanceTimersByTime(400);
888
+ vi.useRealTimers();
889
+
890
+ expect(makeStore({ storage }).getState().trials[0]?.collapsedSections).toEqual({
891
+ 'settings/Shape': true,
892
+ });
893
+ });
894
+
895
+ it('hydrates a version-3 trial, which carries neither field', () => {
896
+ const storage = createMemoryAdapter();
897
+ writeDocument(storage, {
898
+ trials: [{ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW }],
899
+ saves: [],
900
+ layout: {},
901
+ mode: 'auto',
902
+ });
903
+ const trial = makeStore({ storage }).getState().trials[0];
904
+ expect(trial?.title).toBeUndefined();
905
+ expect(trial?.collapsedSections).toBeUndefined();
906
+ });
907
+ });
@@ -1,4 +1,5 @@
1
1
  import { createStore, type StoreApi } from 'zustand/vanilla';
2
+ import { fillConfigDefaults, withValueAtPath } from '../config/path';
2
3
  import {
3
4
  CURRENT_DOCUMENT_VERSION,
4
5
  deleteLegacyKeys,
@@ -13,7 +14,6 @@ import {
13
14
  import { deserializeTrials, emptyUndoStack, serializeTrials } from './helpers';
14
15
  import type {
15
16
  CreateLabStoreOptions,
16
- InstrumentSerializers,
17
17
  LabDocument,
18
18
  LabMode,
19
19
  LabStoreState,
@@ -32,9 +32,15 @@ export interface LabStoreActions {
32
32
  addTrial: (record: Omit<TrialRecord, 'undoStack'>) => void;
33
33
  removeTrial: (id: string) => void;
34
34
  updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
35
- updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
35
+ /** Write one config value. `path` is dotted for a value nested under an
36
+ * `f.group`, and the bare key for one at the root. */
37
+ updateTrialConfig: (id: string, path: string, value: unknown) => void;
36
38
  updateTrialView: (id: string, view: unknown) => void;
37
39
  updateTrialSidebarWidth: (id: string, width: number) => void;
40
+ /** Retitle a trial. `null` returns it to its instrument's name. */
41
+ setTrialTitle: (id: string, title: string | null) => void;
42
+ /** Fold or unfold one of a trial's sections. */
43
+ setTrialSectionCollapsed: (id: string, key: string, collapsed: boolean) => void;
38
44
  updateTrialAnnotations: (id: string, doc: unknown) => void;
39
45
  updateTrialUndoStack: (
40
46
  id: string,
@@ -55,16 +61,14 @@ export interface LabStoreActions {
55
61
  dockPanel: (trialId: string, sectionId?: string) => void;
56
62
  }
57
63
 
58
- /** A lab's store: its state and actions, plus the hook instruments use to
59
- * register how their state is serialized. */
60
- export type LabStore = StoreApi<LabStoreState & LabStoreActions> & {
61
- registerSerializers: (s: InstrumentSerializers) => void;
62
- };
64
+ /** A lab's store: its state and its actions. How each instrument's state is
65
+ * serialized comes in through `CreateLabStoreOptions.serializers`. */
66
+ export type LabStore = StoreApi<LabStoreState & LabStoreActions>;
63
67
 
64
68
  /** Build a lab store, hydrating from storage if anything was saved under the
65
69
  * same key. Writes back are debounced. */
66
70
  export function createLabStore(options: CreateLabStoreOptions): LabStore {
67
- let serializers: InstrumentSerializers = {};
71
+ const serializers = options.serializers ?? {};
68
72
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
69
73
 
70
74
  const hydration = hydrateDocument(options);
@@ -73,8 +77,20 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
73
77
  // Cleared once the legacy keys are actually gone; see the flush.
74
78
  let foldedFromLegacy = hydration.foldedFromLegacy;
75
79
 
76
- const hydratedTrials = deserializeTrials(hydrated.trials, serializers);
77
- const hydratedSnapshots = hydrated.saves;
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
+ }));
78
94
  const hydratedLayout = hydrated.layout;
79
95
  const hydratedMode = hydrated.mode;
80
96
 
@@ -117,15 +133,11 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
117
133
  scheduleFlush();
118
134
  },
119
135
 
120
- updateTrialConfig: (id, key, value) => {
136
+ updateTrialConfig: (id, path, value) => {
121
137
  set((s) => ({
122
- trials: s.trials.map((w) => {
123
- if (w.id !== id) return w;
124
- return {
125
- ...w,
126
- config: { ...(w.config as Record<string, unknown>), [key as string]: value },
127
- };
128
- }),
138
+ trials: s.trials.map((w) =>
139
+ w.id === id ? { ...w, config: withValueAtPath(w.config, path, value) } : w,
140
+ ),
129
141
  }));
130
142
  scheduleFlush();
131
143
  },
@@ -146,6 +158,29 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
146
158
  scheduleFlush();
147
159
  },
148
160
 
161
+ setTrialTitle: (id, title) => {
162
+ set((s) => ({
163
+ trials: s.trials.map((w) => {
164
+ if (w.id !== id) return w;
165
+ const next = title === null ? undefined : title;
166
+ if ((w.title ?? undefined) === next) return w;
167
+ return { ...w, title: next };
168
+ }),
169
+ }));
170
+ scheduleFlush();
171
+ },
172
+
173
+ setTrialSectionCollapsed: (id, key, collapsed) => {
174
+ set((s) => ({
175
+ trials: s.trials.map((w) => {
176
+ if (w.id !== id) return w;
177
+ if (w.collapsedSections?.[key] === collapsed) return w;
178
+ return { ...w, collapsedSections: { ...w.collapsedSections, [key]: collapsed } };
179
+ }),
180
+ }));
181
+ scheduleFlush();
182
+ },
183
+
149
184
  updateTrialAnnotations: (id, doc) => {
150
185
  set((s) => ({
151
186
  trials: s.trials.map((w) =>
@@ -210,7 +245,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
210
245
  return;
211
246
  }
212
247
  const reg = serializers[snapshot.instrumentName];
213
- const restoredState = reg?.deserialize ? reg.deserialize(snapshot.state) : snapshot.state;
248
+ const restoredState = reg?.deserialize
249
+ ? reg.deserialize(snapshot.state, snapshot.config)
250
+ : snapshot.state;
214
251
  set((s) => ({
215
252
  trials: s.trials.map((w) =>
216
253
  w.id === trialId ? { ...w, state: restoredState, config: snapshot.config } : w,
@@ -286,11 +323,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
286
323
  // fold; the flush is what removes the legacy keys.
287
324
  if (foldedFromLegacy) scheduleFlush();
288
325
 
289
- return Object.assign(store, {
290
- registerSerializers(s: InstrumentSerializers) {
291
- serializers = s;
292
- },
293
- });
326
+ return store;
294
327
  }
295
328
 
296
329
  interface HydrateResult {
@@ -1,3 +1,4 @@
1
+ import type { ConfigPath, ValueAtPath } from '../config/types';
1
2
  import type { UndockedPanels } from './undock';
2
3
  /** A trial's undo history, as snapshots of its state either side of the
3
4
  * present. */
@@ -6,6 +7,15 @@ export interface UndoStack {
6
7
  future: unknown[];
7
8
  }
8
9
 
10
+ /** The trial a per-trial call is coming from. `id` is the id `useTileId`
11
+ * scopes a surface tile under, so a consumer keying its own per-trial
12
+ * registry and labkit's tiles agree on the key. */
13
+ export interface TrialInfo<TV = unknown> {
14
+ id: string;
15
+ /** The trial's view, in whatever shape the instrument keeps it. */
16
+ view: TV;
17
+ }
18
+
9
19
  /** One trial as the store holds it: which instrument it runs, that
10
20
  * instrument's config and state, the camera, and the undo history. */
11
21
  export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
@@ -16,11 +26,22 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
16
26
  /** Opaque to labkit: persisted, restored on Reset and handed to the instrument,
17
27
  * but never read into. A 3D lab puts an orbit here and keeps all three. */
18
28
  view: TV;
29
+ /** The config `addTrial` opened this trial on, overlaid on the instrument's
30
+ * defaults. Kept so Reset restores the trial's own subject rather than the
31
+ * bare defaults. */
32
+ configSeed?: Partial<TC>;
19
33
  /** This trial's own tool slot. Undefined means it reads the lab's. */
20
34
  activeToolId?: string | null;
21
35
  /** The extent the trial's sidebar was last dragged to, in pixels. Undefined
22
36
  * until someone moves the seam. */
23
37
  sidebarWidth?: number;
38
+ /** What the title bar reads. Undefined — the state a trial opens in, and the
39
+ * one `setTitle(null)` returns it to — means the instrument's name. */
40
+ title?: string | null;
41
+ /** Which of the trial's collapsible sections are folded, keyed as
42
+ * `TrialChromeContext.collapsedSections` describes. A key that is absent
43
+ * takes the section's own default. */
44
+ collapsedSections?: Record<string, boolean>;
24
45
  /** The marks on this trial's annotation targets, as `AnnotationsApi.toJSON`
25
46
  * wrote them. Opaque here, and absent for a trial whose instrument declares
26
47
  * no `annotations` — or declares its own `storage`. Not in `state`, which
@@ -75,7 +96,9 @@ export interface TrialStateHandle<TS, TC> {
75
96
  state: TS;
76
97
  setState: (next: TS | ((prev: TS) => TS)) => void;
77
98
  config: TC;
78
- setConfig: (key: keyof TC, value: TC[keyof TC]) => void;
99
+ /** Write one config value, by dotted path — `'grid.size'` for a leaf under
100
+ * an `f.group`, `'cellSize'` for one at the root. */
101
+ setConfig: <P extends ConfigPath<TC> & string>(path: P, value: ValueAtPath<TC, P>) => void;
79
102
  }
80
103
 
81
104
  /** Options for `createLabStore`. `storageKey` namespaces the keys written, so
@@ -84,13 +107,31 @@ export interface CreateLabStoreOptions {
84
107
  storageKey: string;
85
108
  storage: StorageAdapter;
86
109
  initialMode?: LabMode;
110
+ /** Each instrument's default config, keyed by instrument name, used to fill
111
+ * the gaps in a stored one. A config saved before its schema grew a branch
112
+ * 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`. */
115
+ configDefaults?: Record<string, () => unknown>;
116
+ /** 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`. */
120
+ serializers?: InstrumentSerializers;
87
121
  }
88
122
 
89
123
  /** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
90
- * instrument whose state is already JSON-safe needs no entry. */
124
+ * instrument whose state is already JSON-safe needs no entry. `deserialize`
125
+ * is handed the config the state was saved against — a trial's own for a
126
+ * reload, the snapshot's for a load — since a state rebuilt without it can
127
+ * disagree with the settings sitting next to it. */
91
128
  export type InstrumentSerializers = Record<
92
129
  string,
93
- { serialize?: (state: unknown) => unknown; deserialize?: (data: unknown) => unknown } | undefined
130
+ | {
131
+ serialize?: (state: unknown) => unknown;
132
+ deserialize?: (data: unknown, config: unknown) => unknown;
133
+ }
134
+ | undefined
94
135
  >;
95
136
 
96
137
  /** A trial as it is persisted: everything but the undo history, which is
@@ -24,6 +24,6 @@ export function useTrialState<TS = unknown, TC = unknown>(): TrialStateHandle<TS
24
24
  state: record.state as TS,
25
25
  config: record.config as TC,
26
26
  setState: (next) => updateTrialState(trialId, next as Parameters<typeof updateTrialState>[1]),
27
- setConfig: (key, value) => updateTrialConfig(trialId, key as never, value as never),
27
+ setConfig: (path, value) => updateTrialConfig(trialId, path, value),
28
28
  };
29
29
  }
@@ -106,3 +106,47 @@ describe('lab-wide config seams', () => {
106
106
  expect(screen.queryByText('Settings')).not.toBeInTheDocument();
107
107
  });
108
108
  });
109
+
110
+ describe('a nested config value, end to end', () => {
111
+ const nestedInstrument = () => {
112
+ const seen: { config: unknown; prev: unknown }[] = [];
113
+ const instrument = defineInstrument({
114
+ name: 'Nested',
115
+ config: f.schema({
116
+ showGrid: f.boolean(true),
117
+ grid: f.group({ size: f.number(20).label('Cell size'), color: f.color('#ffffff') }),
118
+ }),
119
+ initialState: () => ({}),
120
+ onConfigChange: (config, prev, state) => {
121
+ seen.push({ config, prev });
122
+ return state;
123
+ },
124
+ render: (ctx) => (
125
+ <output>{String((ctx.config as { grid: { size: number } }).grid.size)}</output>
126
+ ),
127
+ });
128
+ return { instrument, seen };
129
+ };
130
+
131
+ it('renders a nested leaf and writes the change back into the tree', () => {
132
+ const { instrument } = nestedInstrument();
133
+ render(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
134
+ expect(screen.getByText('20')).toBeInTheDocument();
135
+ fireEvent.change(screen.getByLabelText('Cell size'), { target: { value: '40' } });
136
+ expect(screen.getByText('40')).toBeInTheDocument();
137
+ });
138
+
139
+ it('hands onConfigChange the whole tree, with the sibling branch intact', () => {
140
+ const { instrument, seen } = nestedInstrument();
141
+ render(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
142
+ fireEvent.change(screen.getByLabelText('Cell size'), { target: { value: '40' } });
143
+ expect(seen.at(-1)?.config).toEqual({
144
+ showGrid: true,
145
+ grid: { size: 40, color: '#ffffff' },
146
+ });
147
+ expect(seen.at(-1)?.prev).toEqual({
148
+ showGrid: true,
149
+ grid: { size: 20, color: '#ffffff' },
150
+ });
151
+ });
152
+ });
@@ -164,10 +164,14 @@
164
164
  }
165
165
  }
166
166
 
167
+ .lk-trial__titlebar-lead,
167
168
  .lk-trial__titlebar-actions {
168
169
  display: inline-flex;
169
170
  align-items: center;
170
171
  gap: 2px;
172
+ }
173
+
174
+ .lk-trial__titlebar-actions {
171
175
  margin-left: auto;
172
176
  }
173
177