@weasel-js/labkit 1.4.2 → 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 (105) hide show
  1. package/README.md +20 -2
  2. package/dist/_dts/{CanvasStackContext-kjILVnPj.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-A4R6v4ld.d.ts → frac-B5mB8LsJ.d.ts} +7 -4
  5. package/dist/_dts/{index-CsU9WhjM.d.ts → index-DelFfeV0.d.ts} +15 -7
  6. package/dist/_dts/types-B9_zrHmb.d.ts +166 -0
  7. package/dist/_dts/{types-BbXpvQa8.d.ts → types-BP2OCcpg.d.ts} +21 -3
  8. package/dist/_dts/types-x92Kfeme.d.ts +62 -0
  9. package/dist/_dts/{useTrialState-DKjqpv20.d.ts → useTrialState-D6Vb3T-g.d.ts} +7 -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 +7 -6
  13. package/dist/chrome/index.js +5 -5
  14. package/dist/{chunk-UTOEDPNU.js → chunk-2RQPGOQF.js} +7 -6
  15. package/dist/chunk-2RQPGOQF.js.map +1 -0
  16. package/dist/{chunk-ULDW42CR.js → chunk-5TROQVQ2.js} +25 -21
  17. package/dist/chunk-5TROQVQ2.js.map +1 -0
  18. package/dist/{chunk-XKENZTNE.js → chunk-ASWKLRKJ.js} +65 -49
  19. package/dist/chunk-ASWKLRKJ.js.map +1 -0
  20. package/dist/{chunk-W2FJR5FF.js → chunk-C42T6DJT.js} +74 -76
  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-WS6ZRV75.js → chunk-FQMJUVHJ.js} +3 -3
  25. package/dist/{chunk-WS6ZRV75.js.map → chunk-FQMJUVHJ.js.map} +1 -1
  26. package/dist/{chunk-FJG4PHTL.js → chunk-I6JCVE24.js} +3 -3
  27. package/dist/chunk-I6JCVE24.js.map +1 -0
  28. package/dist/{chunk-NSOVI3AZ.js → chunk-ISCOFAUO.js} +6 -6
  29. package/dist/{chunk-NSOVI3AZ.js.map → chunk-ISCOFAUO.js.map} +1 -1
  30. package/dist/chunk-MHAGC6VD.js +7320 -0
  31. package/dist/chunk-MHAGC6VD.js.map +1 -0
  32. package/dist/{chunk-UDXOYZEC.js → chunk-QAGAHJKZ.js} +4 -4
  33. package/dist/{chunk-UDXOYZEC.js.map → chunk-QAGAHJKZ.js.map} +1 -1
  34. package/dist/{chunk-TN7YSJVU.js → chunk-RS3HRQWU.js} +3 -3
  35. package/dist/{chunk-TN7YSJVU.js.map → chunk-RS3HRQWU.js.map} +1 -1
  36. package/dist/{chunk-D5KQ5OY6.js → chunk-TJ7QY3OC.js} +3 -3
  37. package/dist/{chunk-D5KQ5OY6.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 +6 -5
  41. package/dist/dragdrop/index.js +1 -2
  42. package/dist/index.d.ts +90 -23
  43. package/dist/index.js +199 -117
  44. package/dist/index.js.map +1 -1
  45. package/dist/layers/index.d.ts +7 -6
  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 +88 -20
  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 +2 -2
  59. package/dist/surface/index.js +1 -2
  60. package/dist/ui/layers/index.js +1 -2
  61. package/dist/undo/index.d.ts +6 -6
  62. package/package.json +7 -7
  63. package/src/annotations/ExportMenu.test.tsx +16 -0
  64. package/src/annotations/ExportMenu.tsx +0 -5
  65. package/src/config/builder.test.ts +56 -2
  66. package/src/config/builder.ts +73 -13
  67. package/src/config/index.ts +16 -1
  68. package/src/config/path.test.ts +109 -0
  69. package/src/config/path.ts +76 -0
  70. package/src/config/resolve.test.ts +93 -2
  71. package/src/config/resolve.ts +72 -29
  72. package/src/config/types.ts +99 -9
  73. package/src/config/visible.ts +6 -4
  74. package/src/controls/ControlPanel.stories.tsx +38 -4
  75. package/src/controls/ControlPanel.test.tsx +114 -0
  76. package/src/controls/ControlPanel.tsx +99 -50
  77. package/src/index.ts +16 -1
  78. package/src/instrument/serializers.ts +36 -0
  79. package/src/instrument/types.ts +4 -2
  80. package/src/lab/Lab.test.tsx +29 -1
  81. package/src/lab/Lab.tsx +10 -1
  82. package/src/lab/LabShell.test.tsx +8 -0
  83. package/src/lab/LabShell.tsx +3 -1
  84. package/src/primitives/ZoomControl.less +6 -2
  85. package/src/primitives/ZoomControl.tsx +1 -0
  86. package/src/state/helpers.ts +1 -1
  87. package/src/state/store.test.ts +155 -1
  88. package/src/state/store.ts +30 -24
  89. package/src/state/types.ts +24 -3
  90. package/src/state/useTrialState.ts +1 -1
  91. package/src/trial/Trial.config.test.tsx +44 -0
  92. package/src/trial/Trial.tsx +3 -3
  93. package/src/trial/TrialChrome.tsx +10 -5
  94. package/src/trial/trialOps.ts +4 -8
  95. package/dist/_dts/types-lg4TSCb2.d.ts +0 -164
  96. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +0 -5041
  97. package/dist/chunk-54ZWZ5FQ.js +0 -29997
  98. package/dist/chunk-54ZWZ5FQ.js.map +0 -1
  99. package/dist/chunk-FJG4PHTL.js.map +0 -1
  100. package/dist/chunk-ISSVF5PT.js +0 -7263
  101. package/dist/chunk-ISSVF5PT.js.map +0 -1
  102. package/dist/chunk-ULDW42CR.js.map +0 -1
  103. package/dist/chunk-UTOEDPNU.js.map +0 -1
  104. package/dist/chunk-W2FJR5FF.js.map +0 -1
  105. package/dist/chunk-XKENZTNE.js.map +0 -1
@@ -0,0 +1,36 @@
1
+ import type { InstrumentSerializers } from '../state/types';
2
+ import type { InstrumentList } from './types';
3
+
4
+ /**
5
+ * Each instrument's default config, keyed by name — what `createLabStore`
6
+ * fills a stored config's gaps from, so one saved before its schema grew a
7
+ * branch still loads.
8
+ */
9
+ export function configDefaultsOf(instruments: InstrumentList): Record<string, () => unknown> {
10
+ const out: Record<string, () => unknown> = {};
11
+ for (const instrument of instruments) out[instrument.name] = () => instrument.defaultConfig();
12
+ return out;
13
+ }
14
+
15
+ /**
16
+ * The `serialize` / `deserialize` an instrument list declares, keyed by
17
+ * instrument name — what `createLabStore` needs to rebuild a trial's state
18
+ * from storage.
19
+ *
20
+ * An instrument declaring neither gets no entry, which is what leaves a
21
+ * JSON-safe state to pass through untouched.
22
+ */
23
+ export function serializersOf(instruments: InstrumentList): InstrumentSerializers {
24
+ const out: InstrumentSerializers = {};
25
+ for (const instrument of instruments) {
26
+ const { name, serialize, deserialize } = instrument;
27
+ if (!serialize && !deserialize) continue;
28
+ out[name] = {
29
+ ...(serialize ? { serialize: (state: unknown) => serialize(state) } : {}),
30
+ ...(deserialize
31
+ ? { deserialize: (data: unknown, config: unknown) => deserialize(data, config) }
32
+ : {}),
33
+ };
34
+ }
35
+ return out;
36
+ }
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
2
2
  import type { AnnotationsCapability } from '../annotations/types';
3
3
  import type { ViewportSize, WorldSpec } from '../canvas/worldSpec';
4
4
  import type { TrialContribution } from '../chrome/types';
5
- import type { ConfigSchema } from '../config/types';
5
+ import type { ConfigPath, ConfigSchema } from '../config/types';
6
6
  import type { ConfigField } from '../controls/types';
7
7
  import type { JobCapability, JobHandle } from '../job/types';
8
8
  import type { LoupeDeclaration } from '../loupe/types';
@@ -15,7 +15,9 @@ export interface RenderContext<TS = unknown, TC = unknown> {
15
15
  state: TS;
16
16
  config: TC;
17
17
  setState: (next: TS | ((prev: TS) => TS)) => void;
18
- setConfig: (key: keyof TC, value: unknown) => void;
18
+ /** Write one config value, by dotted path — `'grid.size'` for a leaf under
19
+ * an `f.group`, `'cellSize'` for one at the root. */
20
+ setConfig: (path: ConfigPath<TC>, value: unknown) => void;
19
21
  /** labkit persists `view` and restores it on Reset without ever reading
20
22
  * into it. */
21
23
  trial: TrialInfo & {
@@ -1,7 +1,9 @@
1
1
  import { act, render, screen } from '@testing-library/react';
2
2
  import type { ReactNode } from 'react';
3
- import { describe, expect, it } from 'vitest';
3
+ import { describe, expect, it, vi } from 'vitest';
4
4
  import type { Instrument } from '../instrument/types';
5
+ import { createMemoryAdapter } from '../state/adapters';
6
+ import { labDocumentKey } from '../state/document';
5
7
  import { Lab } from './Lab';
6
8
  import { LabContext, type LabContextValue } from './LabContext';
7
9
 
@@ -135,4 +137,30 @@ describe('<Lab>', () => {
135
137
  /Unknown instrument/,
136
138
  );
137
139
  });
140
+
141
+ // The store reads its serializers while it is being built, so `<Lab>` is
142
+ // the only thing positioned to collect them off the instruments. They went
143
+ // uncollected for months, and an instrument holding anything JSON drops
144
+ // lost it with no error.
145
+ it('gives the store the serializers its instruments declare', () => {
146
+ vi.useFakeTimers();
147
+ const storage = createMemoryAdapter();
148
+ const mapped: Instrument = {
149
+ ...stub,
150
+ name: 'Mapped',
151
+ initialState: () => ({ seen: new Set(['a']) }),
152
+ serialize: (state) => ({ seen: [...(state as { seen: Set<string> }).seen] }),
153
+ deserialize: (data) => ({ seen: new Set((data as { seen: string[] }).seen) }),
154
+ };
155
+
156
+ render(
157
+ <Lab instruments={[mapped]} defaultInstrument="Mapped" storage={storage} storageKey="s" />,
158
+ );
159
+ act(() => vi.advanceTimersByTime(500));
160
+ expect(storage.read(labDocumentKey('s')) ?? '').toContain('"seen":["a"]');
161
+ vi.useRealTimers();
162
+
163
+ mountLab({ instruments: [mapped], defaultInstrument: 'Mapped', storage, storageKey: 's' });
164
+ expect((labRef?.trials[0]?.state as { seen: Set<string> }).seen).toEqual(new Set(['a']));
165
+ });
138
166
  });
package/src/lab/Lab.tsx CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import { useStore } from 'zustand/react';
12
12
  import type { TrialContribution } from '../chrome/types';
13
13
  import type { ConfigRule, ControlRenderer } from '../config/types';
14
+ import { configDefaultsOf, serializersOf } from '../instrument/serializers';
14
15
  import type { InstrumentList } from '../instrument/types';
15
16
  import { noneAdapter } from '../state/adapters';
16
17
  import { LabStoreContext } from '../state/context';
@@ -78,7 +79,15 @@ function buildStore(
78
79
  storageKey: string,
79
80
  initialMode: LabMode,
80
81
  ): LabStore {
81
- const store = createLabStore({ storageKey, storage, initialMode });
82
+ // Hydration reads the serializers, so they go in with the store rather than
83
+ // being registered onto it afterwards.
84
+ const store = createLabStore({
85
+ storageKey,
86
+ storage,
87
+ initialMode,
88
+ serializers: serializersOf(instruments),
89
+ configDefaults: configDefaultsOf(instruments),
90
+ });
82
91
  if (store.getState().trials.length === 0) {
83
92
  const seeded = addTrialOp([], instruments, defaultInstrument);
84
93
  const record = seeded[0];
@@ -39,6 +39,14 @@ describe('LabShell', () => {
39
39
  expect(root.getAttribute('data-wzl-mode')).toMatch(/^(light|dark)$/);
40
40
  });
41
41
 
42
+ // The overlays weasel-ui ships default to the nearest portal host, and
43
+ // `.lk-root` is where labkit's element defaults live — not the themed
44
+ // wrapper above it.
45
+ test('marks lk-root as the portal host for overlays', () => {
46
+ const { container } = render(<LabShell title="t">x</LabShell>);
47
+ expect(container.querySelector('.lk-root')?.hasAttribute('data-wzl-portal-host')).toBe(true);
48
+ });
49
+
42
50
  test('always applies lk-root class', () => {
43
51
  const { container } = render(<LabShell title="t">x</LabShell>);
44
52
  expect(container.querySelector('.lk-root')).not.toBeNull();
@@ -24,7 +24,9 @@ export function LabShell({ title, children, header, footer, mode = 'auto' }: Lab
24
24
  const outer = useThemeOptional();
25
25
 
26
26
  const shell = (
27
- <div className="lk-root lk-shell">
27
+ // Overlays portal here rather than to the themed wrapper above: `.lk-root`
28
+ // carries the element defaults a lab's bare markup is styled by.
29
+ <div className="lk-root lk-shell" data-wzl-portal-host="">
28
30
  <header className="lk-shell-header">
29
31
  <h1 className="lk-shell-title">{title}</h1>
30
32
  {header && <div className="lk-shell-header-actions">{header}</div>}
@@ -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', () => {
@@ -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,7 +32,9 @@ 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;
38
40
  /** Retitle a trial. `null` returns it to its instrument's name. */
@@ -59,16 +61,14 @@ export interface LabStoreActions {
59
61
  dockPanel: (trialId: string, sectionId?: string) => void;
60
62
  }
61
63
 
62
- /** A lab's store: its state and actions, plus the hook instruments use to
63
- * register how their state is serialized. */
64
- export type LabStore = StoreApi<LabStoreState & LabStoreActions> & {
65
- registerSerializers: (s: InstrumentSerializers) => void;
66
- };
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>;
67
67
 
68
68
  /** Build a lab store, hydrating from storage if anything was saved under the
69
69
  * same key. Writes back are debounced. */
70
70
  export function createLabStore(options: CreateLabStoreOptions): LabStore {
71
- let serializers: InstrumentSerializers = {};
71
+ const serializers = options.serializers ?? {};
72
72
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
73
73
 
74
74
  const hydration = hydrateDocument(options);
@@ -77,8 +77,20 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
77
77
  // Cleared once the legacy keys are actually gone; see the flush.
78
78
  let foldedFromLegacy = hydration.foldedFromLegacy;
79
79
 
80
- const hydratedTrials = deserializeTrials(hydrated.trials, serializers);
81
- 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
+ }));
82
94
  const hydratedLayout = hydrated.layout;
83
95
  const hydratedMode = hydrated.mode;
84
96
 
@@ -121,15 +133,11 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
121
133
  scheduleFlush();
122
134
  },
123
135
 
124
- updateTrialConfig: (id, key, value) => {
136
+ updateTrialConfig: (id, path, value) => {
125
137
  set((s) => ({
126
- trials: s.trials.map((w) => {
127
- if (w.id !== id) return w;
128
- return {
129
- ...w,
130
- config: { ...(w.config as Record<string, unknown>), [key as string]: value },
131
- };
132
- }),
138
+ trials: s.trials.map((w) =>
139
+ w.id === id ? { ...w, config: withValueAtPath(w.config, path, value) } : w,
140
+ ),
133
141
  }));
134
142
  scheduleFlush();
135
143
  },
@@ -237,7 +245,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
237
245
  return;
238
246
  }
239
247
  const reg = serializers[snapshot.instrumentName];
240
- 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;
241
251
  set((s) => ({
242
252
  trials: s.trials.map((w) =>
243
253
  w.id === trialId ? { ...w, state: restoredState, config: snapshot.config } : w,
@@ -313,11 +323,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
313
323
  // fold; the flush is what removes the legacy keys.
314
324
  if (foldedFromLegacy) scheduleFlush();
315
325
 
316
- return Object.assign(store, {
317
- registerSerializers(s: InstrumentSerializers) {
318
- serializers = s;
319
- },
320
- });
326
+ return store;
321
327
  }
322
328
 
323
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. */
@@ -95,7 +96,9 @@ export interface TrialStateHandle<TS, TC> {
95
96
  state: TS;
96
97
  setState: (next: TS | ((prev: TS) => TS)) => void;
97
98
  config: TC;
98
- 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;
99
102
  }
100
103
 
101
104
  /** Options for `createLabStore`. `storageKey` namespaces the keys written, so
@@ -104,13 +107,31 @@ export interface CreateLabStoreOptions {
104
107
  storageKey: string;
105
108
  storage: StorageAdapter;
106
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;
107
121
  }
108
122
 
109
123
  /** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
110
- * 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. */
111
128
  export type InstrumentSerializers = Record<
112
129
  string,
113
- { 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
114
135
  >;
115
136
 
116
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
+ });
@@ -239,11 +239,11 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
239
239
  updateTrialState(record.id, next);
240
240
  bus.emit('state.change');
241
241
  },
242
- setConfig: (key, value) => {
243
- const evt = `config.change:${String(key)}`;
242
+ setConfig: (path, value) => {
243
+ const evt = `config.change:${path}`;
244
244
  snapshotIfNeeded('config.change');
245
245
  snapshotIfNeeded(evt);
246
- updateTrialConfig(record.id, key as never, value as never);
246
+ updateTrialConfig(record.id, path, value);
247
247
  bus.emit('config.change');
248
248
  bus.emit(evt);
249
249
  },
@@ -9,6 +9,7 @@ import { TitleBarRegion } from '../chrome/regions/TitleBarRegion';
9
9
  import { ToolbarRegion } from '../chrome/regions/ToolbarRegion';
10
10
  import { ViewportRegion } from '../chrome/regions/ViewportRegion';
11
11
  import type { TrialChromeContext, TrialContribution, TrialRegion } from '../chrome/types';
12
+ import { hasConfigPath, schemaNodeAtPath, withValueAtPath } from '../config/path';
12
13
  import { useConfigSchema } from '../config/useConfigSchema';
13
14
  import type { Instrument } from '../instrument/types';
14
15
  import type { JobHandle } from '../job/types';
@@ -135,16 +136,20 @@ export function TrialChrome({
135
136
  configSchema,
136
137
  configFields: instrument.config ? [] : (instrument.configSchema?.() ?? []),
137
138
  config: record.config,
138
- setConfig: (key, value) => {
139
+ setConfig: (path, value) => {
139
140
  const prevConfig = record.config as Record<string, unknown>;
140
- if (process.env.NODE_ENV !== 'production' && !(key in prevConfig)) {
141
+ if (
142
+ process.env.NODE_ENV !== 'production' &&
143
+ !hasConfigPath(prevConfig, path) &&
144
+ schemaNodeAtPath(configSchema.group, path) === undefined
145
+ ) {
141
146
  console.warn(
142
- `[labkit] setConfig: unknown key "${key}" for instrument "${record.instrumentName}"`,
147
+ `[labkit] setConfig: unknown path "${path}" for instrument "${record.instrumentName}"`,
143
148
  );
144
149
  }
145
- updateTrialConfig(trialId, key as never, value as never);
150
+ updateTrialConfig(trialId, path, value);
146
151
  if (instrument.onConfigChange) {
147
- const nextConfig = { ...prevConfig, [key]: value };
152
+ const nextConfig = withValueAtPath(prevConfig, path, value);
148
153
  const nextState = instrument.onConfigChange(nextConfig, prevConfig, record.state);
149
154
  updateTrialState(trialId, nextState as never);
150
155
  }
@@ -1,3 +1,4 @@
1
+ import { fillConfigDefaults } from '../config/path';
1
2
  import type { Instrument, InstrumentList } from '../instrument/types';
2
3
  import type { TrialRecord } from '../state/types';
3
4
 
@@ -27,15 +28,10 @@ export interface AddTrialOptions<TC = Record<string, unknown>> {
27
28
  config?: Partial<TC>;
28
29
  }
29
30
 
30
- /** `defaultConfig()` with `seed` written over it. Config is flat — a leaf's
31
- * path is its config key — so this is the whole merge. */
31
+ /** `defaultConfig()` with `seed` written over it, down the tree: a seed naming
32
+ * one leaf of a group leaves that group's other leaves at their defaults. */
32
33
  function seedConfig<TC>(defaults: TC, seed: Partial<TC> | undefined): TC {
33
- if (!seed) return defaults;
34
- const out = { ...defaults };
35
- for (const [key, value] of Object.entries(seed)) {
36
- if (value !== undefined) (out as Record<string, unknown>)[key] = value;
37
- }
38
- return out;
34
+ return seed ? fillConfigDefaults(seed, defaults) : defaults;
39
35
  }
40
36
 
41
37
  /** Append a new trial running `instrumentName`, at that instrument's default