@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
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `annotations.targets` is declared once per instrument and called once per
3
+ * trial, so everything here runs two trials of one instrument: with a single
4
+ * trial open, a `targets` that ignored the trial it was asked about would look
5
+ * identical.
6
+ */
7
+ import { act, fireEvent, render, screen } from '@testing-library/react';
8
+ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import type { AnnotationTarget } from '../annotations/types';
10
+ import { defineInstrument } from '../instrument/defineInstrument';
11
+ import { Lab } from '../lab/Lab';
12
+ import { LabContext, type LabContextValue } from '../lab/LabContext';
13
+ import type { TrialInfo } from '../state/types';
14
+
15
+ beforeAll(() => {
16
+ HTMLCanvasElement.prototype.getContext = vi.fn(
17
+ () => null,
18
+ ) as unknown as HTMLCanvasElement['getContext'];
19
+ });
20
+
21
+ const asked: TrialInfo[] = [];
22
+
23
+ /** The last thing `targets` was told about `id`. */
24
+ function lastAsk(id: string): TrialInfo {
25
+ const found = [...asked].reverse().find((t) => t.id === id);
26
+ if (!found) throw new Error(`targets was never called for ${id}`);
27
+ return found;
28
+ }
29
+
30
+ const pane: AnnotationTarget = { id: 'pane', ref: { current: null }, content: { w: 200, h: 100 } };
31
+
32
+ const subject = defineInstrument<Record<string, never>, { subject: string }>({
33
+ name: 'Subject',
34
+ defaultConfig: () => ({ subject: 'none' }),
35
+ initialState: () => ({}),
36
+ render: (ctx) => (
37
+ <button
38
+ type="button"
39
+ data-testid={`zoom-${ctx.trial.id}`}
40
+ onClick={() => ctx.trial.setView({ zoom: 3, pan: { x: 0, y: 0 } })}
41
+ >
42
+ zoom
43
+ </button>
44
+ ),
45
+ annotations: {
46
+ targets: (_state, _config, trial) => {
47
+ asked.push({ id: trial.id, view: trial.view });
48
+ return [pane];
49
+ },
50
+ },
51
+ });
52
+
53
+ let labRef: LabContextValue | null = null;
54
+
55
+ function CaptureLab() {
56
+ return (
57
+ <LabContext.Consumer>
58
+ {(value) => {
59
+ if (value) labRef = value;
60
+ return null;
61
+ }}
62
+ </LabContext.Consumer>
63
+ );
64
+ }
65
+
66
+ function mountTwoTrials(): [string, string] {
67
+ render(
68
+ <Lab instruments={[subject]} defaultInstrument="Subject">
69
+ <CaptureLab />
70
+ </Lab>,
71
+ );
72
+ act(() => labRef?.addTrial('Subject'));
73
+ const ids = labRef?.trials.map((t) => t.id) ?? [];
74
+ const [a, b] = ids;
75
+ if (!a || !b) throw new Error('expected two trials');
76
+ return [a, b];
77
+ }
78
+
79
+ describe('annotations.targets', () => {
80
+ beforeEach(() => {
81
+ asked.length = 0;
82
+ labRef = null;
83
+ });
84
+
85
+ it('is told which trial is asking', () => {
86
+ const [a, b] = mountTwoTrials();
87
+ expect(new Set(asked.map((t) => t.id))).toEqual(new Set([a, b]));
88
+ });
89
+
90
+ it("is told that trial's own view, not the other's", () => {
91
+ const [a, b] = mountTwoTrials();
92
+ fireEvent.click(screen.getByTestId(`zoom-${a}`));
93
+ expect(lastAsk(a).view).toEqual({ zoom: 3, pan: { x: 0, y: 0 } });
94
+ expect(lastAsk(b).view).toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
95
+ });
96
+ });
@@ -1,6 +1,8 @@
1
1
  import { fireEvent, render, screen, within } from '@testing-library/react';
2
- import type { ReactNode } from 'react';
2
+ import { type ReactNode, useState } from 'react';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
+ import { useStore } from 'zustand/react';
5
+ import { f } from '../config/builder';
4
6
  import type { Instrument } from '../instrument/types';
5
7
  import { Lab } from '../lab/Lab';
6
8
  import { LabContext, type LabContextValue } from '../lab/LabContext';
@@ -9,6 +11,8 @@ import { createLabStore } from '../state/store';
9
11
  import type { SavedSnapshot, TrialRecord } from '../state/types';
10
12
  import { TrialChrome } from './TrialChrome';
11
13
 
14
+ const Glyph = () => <svg />;
15
+
12
16
  const stubInstrument: Instrument = {
13
17
  name: 'Stub',
14
18
  defaultConfig: () => ({}),
@@ -27,16 +31,32 @@ const stubRecord: TrialRecord = {
27
31
 
28
32
  type ChromeProps = Parameters<typeof TrialChrome>[0];
29
33
 
34
+ /** A store holding the stub trial, as `Trial` would have put it there. Title
35
+ * and section folds live on the record, so a test that outlives one mount
36
+ * keeps its own store and hands it to both. */
37
+ function makeChromeStore() {
38
+ const store = createLabStore({
39
+ storageKey: 'test',
40
+ storage: { read: () => null, write: () => {} },
41
+ });
42
+ const { undoStack: _undo, ...seed } = stubRecord;
43
+ store.getState().addTrial(seed);
44
+ return store;
45
+ }
46
+
30
47
  function ChromeHarness({
31
48
  children,
32
49
  labOverrides,
33
50
  instrument = stubInstrument,
51
+ store: storeProp,
34
52
  ...props
35
- }: { children?: ReactNode; labOverrides?: Partial<LabContextValue> } & Partial<ChromeProps>) {
36
- const store = createLabStore({
37
- storageKey: 'test',
38
- storage: { read: () => null, write: () => {} },
39
- });
53
+ }: {
54
+ children?: ReactNode;
55
+ labOverrides?: Partial<LabContextValue>;
56
+ store?: ReturnType<typeof createLabStore>;
57
+ } & Partial<ChromeProps>) {
58
+ const [ownStore] = useState(makeChromeStore);
59
+ const store = storeProp ?? ownStore;
40
60
  const labCtx: LabContextValue = {
41
61
  instruments: [instrument],
42
62
  trials: [stubRecord],
@@ -56,20 +76,31 @@ function ChromeHarness({
56
76
  return (
57
77
  <LabStoreContext.Provider value={{ store }}>
58
78
  <LabContext.Provider value={labCtx}>
59
- <TrialChrome
60
- trialId="ws-1"
61
- record={stubRecord}
62
- instrument={instrument}
63
- isLastTrial={false}
64
- {...props}
65
- >
79
+ <ChromeFromStore store={store} instrument={instrument} {...props}>
66
80
  {children ?? <div data-testid="content">content</div>}
67
- </TrialChrome>
81
+ </ChromeFromStore>
68
82
  </LabContext.Provider>
69
83
  </LabStoreContext.Provider>
70
84
  );
71
85
  }
72
86
 
87
+ /** Reads the record out of the store the way `Trial` does, so a mutation the
88
+ * chrome writes comes back to it. */
89
+ function ChromeFromStore({
90
+ store,
91
+ children,
92
+ ...props
93
+ }: { store: ReturnType<typeof createLabStore>; children: ReactNode } & Partial<ChromeProps> &
94
+ Pick<ChromeProps, 'instrument'>) {
95
+ const record = useStore(store, (s) => s.trials.find((t) => t.id === 'ws-1'));
96
+ if (!record) throw new Error('no stub trial in the store');
97
+ return (
98
+ <TrialChrome trialId="ws-1" record={record} isLastTrial={false} {...props}>
99
+ {children}
100
+ </TrialChrome>
101
+ );
102
+ }
103
+
73
104
  describe('<TrialChrome>', () => {
74
105
  it('renders children in the content area', () => {
75
106
  render(<ChromeHarness />);
@@ -131,6 +162,49 @@ describe('<TrialChrome>', () => {
131
162
  expect(screen.getByText('ready')).toBeInTheDocument();
132
163
  });
133
164
 
165
+ it('leads the title bar with a contribution that does not set `end`', () => {
166
+ const { container } = render(
167
+ <ChromeHarness
168
+ chrome={[
169
+ {
170
+ id: 'subject',
171
+ region: 'titlebar',
172
+ item: { icon: Glyph, label: 'Pick subject', onActivate: () => {} },
173
+ },
174
+ ]}
175
+ />,
176
+ );
177
+ const titlebar = container.querySelector('.lk-trial__titlebar');
178
+ if (!titlebar) throw new Error('no title bar');
179
+ const lead = screen.getByRole('button', { name: 'Pick subject' });
180
+ const heading = titlebar.querySelector('.lk-trial__title');
181
+ if (!heading) throw new Error('no title');
182
+ expect(titlebar.querySelector('.lk-trial__titlebar-lead')).toContainElement(lead);
183
+ expect(lead.compareDocumentPosition(heading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
184
+ });
185
+
186
+ it('reads the instrument name until a contribution calls setTitle', () => {
187
+ render(
188
+ <ChromeHarness
189
+ chrome={[
190
+ {
191
+ id: 'rename',
192
+ region: 'titlebar',
193
+ item: {
194
+ icon: Glyph,
195
+ label: 'Rename',
196
+ onActivate: (ctx) => ctx.setTitle('Sprocket 7'),
197
+ },
198
+ },
199
+ ]}
200
+ />,
201
+ );
202
+ expect(screen.getByRole('region', { name: 'Trial Stub' })).toBeInTheDocument();
203
+ fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
204
+ expect(screen.getByText('Sprocket 7')).toBeInTheDocument();
205
+ expect(screen.getByRole('region', { name: 'Trial Sprocket 7' })).toBeInTheDocument();
206
+ });
207
+
134
208
  it('Cmd+S triggers saveSnapshot', () => {
135
209
  const saveSnapshot = vi.fn();
136
210
  render(<ChromeHarness labOverrides={{ saveSnapshot }} />);
@@ -168,3 +242,86 @@ describe('chrome regions in a mounted lab', () => {
168
242
  expect(document.querySelector('.lk-lab')).toBeTruthy();
169
243
  });
170
244
  });
245
+
246
+ describe('<TrialChrome> — state that outlives a mount', () => {
247
+ const renameChrome: ChromeProps['chrome'] = [
248
+ {
249
+ id: 'rename',
250
+ region: 'titlebar',
251
+ item: {
252
+ icon: Glyph,
253
+ label: 'Rename',
254
+ onActivate: (ctx) => ctx.setTitle('Sprocket 7'),
255
+ },
256
+ },
257
+ ];
258
+
259
+ const sectionChrome: ChromeProps['chrome'] = [
260
+ {
261
+ id: 'notes',
262
+ region: 'sidebar',
263
+ item: { title: 'Notes', body: <p data-testid="notes-body">notes</p> },
264
+ },
265
+ ];
266
+
267
+ it('keeps a title across a remount', () => {
268
+ const store = makeChromeStore();
269
+ const first = render(<ChromeHarness store={store} chrome={renameChrome} />);
270
+ fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
271
+ first.unmount();
272
+
273
+ render(<ChromeHarness store={store} chrome={renameChrome} />);
274
+ expect(screen.getByRole('region', { name: 'Trial Sprocket 7' })).toBeInTheDocument();
275
+ });
276
+
277
+ it('keeps a folded sidebar section across a remount', () => {
278
+ const store = makeChromeStore();
279
+ const first = render(<ChromeHarness store={store} chrome={sectionChrome} />);
280
+ expect(screen.getByTestId('notes-body')).toBeInTheDocument();
281
+ fireEvent.click(screen.getByRole('button', { name: 'Notes' }));
282
+ expect(screen.queryByTestId('notes-body')).toBeNull();
283
+ first.unmount();
284
+
285
+ render(<ChromeHarness store={store} chrome={sectionChrome} />);
286
+ expect(screen.getByRole('button', { name: 'Notes' })).toHaveAttribute('aria-expanded', 'false');
287
+ expect(screen.queryByTestId('notes-body')).toBeNull();
288
+ });
289
+
290
+ it('keeps a folded settings group across a remount', () => {
291
+ const sectioned: Instrument = {
292
+ ...stubInstrument,
293
+ config: f.schema({ size: f.number(1).section('Shape') }),
294
+ };
295
+ const store = makeChromeStore();
296
+ const first = render(<ChromeHarness store={store} instrument={sectioned} />);
297
+ const twisty = screen.getByRole('button', { name: 'Shape' });
298
+ expect(twisty).toHaveAttribute('aria-expanded', 'true');
299
+ fireEvent.click(twisty);
300
+ first.unmount();
301
+
302
+ render(<ChromeHarness store={store} instrument={sectioned} />);
303
+ expect(screen.getByRole('button', { name: 'Shape' })).toHaveAttribute('aria-expanded', 'false');
304
+ });
305
+
306
+ it('reopens a section that starts collapsed, and keeps it open across a remount', () => {
307
+ const collapsedChrome: ChromeProps['chrome'] = [
308
+ {
309
+ id: 'notes',
310
+ region: 'sidebar',
311
+ item: {
312
+ title: 'Notes',
313
+ defaultCollapsed: true,
314
+ body: <p data-testid="notes-body">notes</p>,
315
+ },
316
+ },
317
+ ];
318
+ const store = makeChromeStore();
319
+ const first = render(<ChromeHarness store={store} chrome={collapsedChrome} />);
320
+ expect(screen.queryByTestId('notes-body')).toBeNull();
321
+ fireEvent.click(screen.getByRole('button', { name: 'Notes' }));
322
+ first.unmount();
323
+
324
+ render(<ChromeHarness store={store} chrome={collapsedChrome} />);
325
+ expect(screen.getByTestId('notes-body')).toBeInTheDocument();
326
+ });
327
+ });
@@ -130,7 +130,9 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
130
130
  const annotationsCap = instrument.annotations;
131
131
  const targetsRef = useRef<() => readonly AnnotationTargetInfo[]>(() => []);
132
132
  targetsRef.current = () =>
133
- annotationsCap ? annotationsCap.targets(record.state, record.config) : [];
133
+ annotationsCap
134
+ ? annotationsCap.targets(record.state, record.config, { id: record.id, view: record.view })
135
+ : [];
134
136
 
135
137
  // One store for the trial's lifetime. Marks do not survive a reload yet —
136
138
  // the storage slot is 3d.
@@ -237,11 +239,11 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
237
239
  updateTrialState(record.id, next);
238
240
  bus.emit('state.change');
239
241
  },
240
- setConfig: (key, value) => {
241
- const evt = `config.change:${String(key)}`;
242
+ setConfig: (path, value) => {
243
+ const evt = `config.change:${path}`;
242
244
  snapshotIfNeeded('config.change');
243
245
  snapshotIfNeeded(evt);
244
- updateTrialConfig(record.id, key as never, value as never);
246
+ updateTrialConfig(record.id, path, value);
245
247
  bus.emit('config.change');
246
248
  bus.emit(evt);
247
249
  },
@@ -474,6 +476,7 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
474
476
  capability={annotationsCap}
475
477
  state={record.state}
476
478
  config={record.config}
479
+ trial={{ id: record.id, view: record.view }}
477
480
  annotations={annotations}
478
481
  activeToolId={resolvedToolId}
479
482
  />
@@ -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';
@@ -62,6 +63,10 @@ export interface TrialChromeProps {
62
63
 
63
64
  const NO_OP = (): void => {};
64
65
 
66
+ /** One frozen empty map, so a trial that has folded nothing keeps a stable
67
+ * context identity across renders. */
68
+ const NO_SECTIONS: Readonly<Record<string, boolean>> = Object.freeze({});
69
+
65
70
  /** The frame around a running instrument. Builds one chrome context, assembles
66
71
  * the contributions the instrument, the runtime and the lab declare, and hands
67
72
  * each region its slice. */
@@ -85,6 +90,8 @@ export function TrialChrome({
85
90
  if (!storeCtx) throw new Error('[labkit] TrialChrome requires <LabStoreProvider>');
86
91
  const updateTrialView = useStore(storeCtx.store, (s) => s.updateTrialView);
87
92
  const updateTrialSidebarWidth = useStore(storeCtx.store, (s) => s.updateTrialSidebarWidth);
93
+ const setTrialTitle = useStore(storeCtx.store, (s) => s.setTrialTitle);
94
+ const setTrialSectionCollapsed = useStore(storeCtx.store, (s) => s.setTrialSectionCollapsed);
88
95
  const updateTrialConfig = useStore(storeCtx.store, (s) => s.updateTrialConfig);
89
96
  const updateTrialState = useStore(storeCtx.store, (s) => s.updateTrialState);
90
97
  const undockPanelAction = useStore(storeCtx.store, (s) => s.undockPanel);
@@ -104,6 +111,9 @@ export function TrialChrome({
104
111
 
105
112
  const configSchema = useConfigSchema(instrument);
106
113
 
114
+ const title = record.title ?? record.instrumentName;
115
+ const collapsedSections = record.collapsedSections ?? NO_SECTIONS;
116
+
107
117
  const ctx = useMemo<TrialChromeContext>(() => {
108
118
  const setZoom = (z: number): void => {
109
119
  if (!view2d) return;
@@ -112,6 +122,8 @@ export function TrialChrome({
112
122
  return {
113
123
  trialId,
114
124
  instrumentName: record.instrumentName,
125
+ title,
126
+ setTitle: (next) => setTrialTitle(trialId, next),
115
127
  isLastTrial,
116
128
  zoom: view2d ? view2d.zoom : null,
117
129
  setZoom,
@@ -124,20 +136,26 @@ export function TrialChrome({
124
136
  configSchema,
125
137
  configFields: instrument.config ? [] : (instrument.configSchema?.() ?? []),
126
138
  config: record.config,
127
- setConfig: (key, value) => {
139
+ setConfig: (path, value) => {
128
140
  const prevConfig = record.config as Record<string, unknown>;
129
- 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
+ ) {
130
146
  console.warn(
131
- `[labkit] setConfig: unknown key "${key}" for instrument "${record.instrumentName}"`,
147
+ `[labkit] setConfig: unknown path "${path}" for instrument "${record.instrumentName}"`,
132
148
  );
133
149
  }
134
- updateTrialConfig(trialId, key as never, value as never);
150
+ updateTrialConfig(trialId, path, value);
135
151
  if (instrument.onConfigChange) {
136
- const nextConfig = { ...prevConfig, [key]: value };
152
+ const nextConfig = withValueAtPath(prevConfig, path, value);
137
153
  const nextState = instrument.onConfigChange(nextConfig, prevConfig, record.state);
138
154
  updateTrialState(trialId, nextState as never);
139
155
  }
140
156
  },
157
+ collapsedSections,
158
+ setSectionCollapsed: (key, collapsed) => setTrialSectionCollapsed(trialId, key, collapsed),
141
159
  undockedPanels: undockedIds,
142
160
  undockPanel: (sectionId, as) => undockPanelAction(trialId, sectionId, as),
143
161
  dockPanel: (sectionId) => dockPanelAction(trialId, sectionId),
@@ -155,6 +173,7 @@ export function TrialChrome({
155
173
  record,
156
174
  instrument,
157
175
  lab,
176
+ title,
158
177
  isLastTrial,
159
178
  updateTrialView,
160
179
  updateTrialConfig,
@@ -168,6 +187,9 @@ export function TrialChrome({
168
187
  undockedIds,
169
188
  undockPanelAction,
170
189
  dockPanelAction,
190
+ setTrialTitle,
191
+ setTrialSectionCollapsed,
192
+ collapsedSections,
171
193
  ]);
172
194
 
173
195
  const contributions = useMemo(
@@ -203,11 +225,14 @@ export function TrialChrome({
203
225
  return (
204
226
  <section
205
227
  className="lk-trial"
206
- aria-label={`Trial ${record.instrumentName}`}
228
+ aria-label={`Trial ${title}`}
207
229
  tabIndex={-1}
208
230
  onKeyDown={handleKeyDown}
209
231
  >
210
- <TrialTitleBar title={record.instrumentName}>
232
+ <TrialTitleBar
233
+ title={title}
234
+ lead={<TitleBarRegion placement="lead" contributions={inRegion('titlebar')} ctx={ctx} />}
235
+ >
211
236
  <TitleBarRegion contributions={inRegion('titlebar')} ctx={ctx} />
212
237
  </TrialTitleBar>
213
238
  <div className="lk-trial__toolbar">
@@ -6,15 +6,18 @@ import { useTrialDrag } from './TrialDragContext';
6
6
  /** Props for `<TrialTitleBar>`. */
7
7
  export interface TrialTitleBarProps {
8
8
  title: string;
9
+ /** Rendered at the leading edge, before the title. */
10
+ lead?: ReactNode;
9
11
  children?: ReactNode;
10
12
  }
11
13
 
12
14
  // `useDragHandle` needs a node id and windease's DragProvider, so the draggable
13
15
  // form is a separate component rather than a conditional hook.
14
- function Draggable({ nodeId, title, children }: TrialTitleBarProps & { nodeId: NodeId }) {
16
+ function Draggable({ nodeId, title, lead, children }: TrialTitleBarProps & { nodeId: NodeId }) {
15
17
  const handlers = useDragHandle(nodeId);
16
18
  return (
17
19
  <div className="lk-trial__titlebar lk-trial__titlebar--draggable" {...handlers}>
20
+ {lead}
18
21
  <span className="lk-trial__title">{title}</span>
19
22
  {children}
20
23
  </div>
@@ -24,16 +27,17 @@ function Draggable({ nodeId, title, children }: TrialTitleBarProps & { nodeId: N
24
27
  /** A trial's title bar. When the workspace allows reordering the whole bar is
25
28
  * the drag surface — there is no separate grip, because a window's title bar
26
29
  * is already the thing you expect to drag. */
27
- export function TrialTitleBar({ title, children }: TrialTitleBarProps) {
30
+ export function TrialTitleBar({ title, lead, children }: TrialTitleBarProps) {
28
31
  const drag = useTrialDrag();
29
32
  if (drag)
30
33
  return (
31
- <Draggable nodeId={drag.nodeId} title={title}>
34
+ <Draggable nodeId={drag.nodeId} title={title} lead={lead}>
32
35
  {children}
33
36
  </Draggable>
34
37
  );
35
38
  return (
36
39
  <div className="lk-trial__titlebar">
40
+ {lead}
37
41
  <span className="lk-trial__title">{title}</span>
38
42
  {children}
39
43
  </div>
@@ -4,6 +4,7 @@ export type { TrialBodyProps } from './TrialBody';
4
4
  export { TrialBody } from './TrialBody';
5
5
  export type { TrialChromeProps, UndoBindings } from './TrialChrome';
6
6
  export { TrialChrome } from './TrialChrome';
7
+ export type { AddTrialOptions } from './trialOps';
7
8
  export {
8
9
  addTrial,
9
10
  cloneTrial,
@@ -26,7 +26,23 @@ const counterWithCanvas: Instrument<CounterState, CounterConfig> = {
26
26
  },
27
27
  };
28
28
 
29
- const instruments: Instrument[] = [counter as Instrument, counterWithCanvas as Instrument];
29
+ interface SubjectConfig {
30
+ subject: string;
31
+ zoom: number;
32
+ }
33
+
34
+ const subjectInstrument: Instrument<{ label: string }, SubjectConfig> = {
35
+ name: 'Subject',
36
+ defaultConfig: () => ({ subject: 'none', zoom: 1 }),
37
+ initialState: (config) => ({ label: config.subject.toUpperCase() }),
38
+ render: () => null,
39
+ };
40
+
41
+ const instruments: Instrument[] = [
42
+ counter as Instrument,
43
+ counterWithCanvas as Instrument,
44
+ subjectInstrument as Instrument,
45
+ ];
30
46
 
31
47
  function head<T>(arr: T[]): T {
32
48
  const first = arr[0];
@@ -69,6 +85,28 @@ describe('addTrial', () => {
69
85
  addTrial(arr, instruments, 'Counter');
70
86
  expect(arr).toHaveLength(0);
71
87
  });
88
+
89
+ it('overlays a seed config on the instrument defaults', () => {
90
+ const out = addTrial([], instruments, 'Subject', { config: { subject: 'brick' } });
91
+ expect(out[0]?.config).toEqual({ subject: 'brick', zoom: 1 });
92
+ });
93
+
94
+ it('seeds the config before initialState reads it', () => {
95
+ const out = addTrial([], instruments, 'Subject', { config: { subject: 'brick' } });
96
+ expect(out[0]?.state).toEqual({ label: 'BRICK' });
97
+ });
98
+
99
+ it('gives each trial the seed it was opened with', () => {
100
+ const a = addTrial([], instruments, 'Subject', { config: { subject: 'one' } });
101
+ const b = addTrial(a, instruments, 'Subject', { config: { subject: 'two' } });
102
+ expect(b.map((t) => (t.config as SubjectConfig).subject)).toEqual(['one', 'two']);
103
+ expect(b.map((t) => (t.state as { label: string }).label)).toEqual(['ONE', 'TWO']);
104
+ });
105
+
106
+ it('ignores a seed key whose value is undefined', () => {
107
+ const out = addTrial([], instruments, 'Subject', { config: { subject: undefined } });
108
+ expect(out[0]?.config).toEqual({ subject: 'none', zoom: 1 });
109
+ });
72
110
  });
73
111
 
74
112
  describe('cloneTrial', () => {
@@ -139,6 +177,16 @@ describe('resetTrial', () => {
139
177
  resetTrial(arr, head(arr).id, instruments);
140
178
  expect((arr[0]?.config as CounterConfig).step).toBe(99);
141
179
  });
180
+
181
+ it('restores the seed the trial was opened with, not the bare defaults', () => {
182
+ let arr = addTrial([], instruments, 'Subject', { config: { subject: 'one' } });
183
+ arr = addTrial(arr, instruments, 'Subject', { config: { subject: 'two' } });
184
+ head(arr).config = { subject: 'edited', zoom: 4 } as SubjectConfig;
185
+ const reset = resetTrial(arr, head(arr).id, instruments);
186
+ expect(reset[0]?.config).toEqual({ subject: 'one', zoom: 1 });
187
+ expect(reset[0]?.state).toEqual({ label: 'ONE' });
188
+ expect(reset[1]?.config).toEqual({ subject: 'two', zoom: 1 });
189
+ });
142
190
  });
143
191
 
144
192
  describe('reorderTrials', () => {
@@ -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
 
@@ -19,15 +20,31 @@ function initialView(instrument: Instrument): TrialRecord['view'] {
19
20
  return declared ? structuredClone(declared) : { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
20
21
  }
21
22
 
22
- /** Append a new trial running `instrumentName`, at that instrument's
23
- * default config and initial state. */
23
+ /** What a trial opens on beyond its instrument's own defaults. */
24
+ export interface AddTrialOptions<TC = Record<string, unknown>> {
25
+ /** Overlaid on `defaultConfig()` before `initialState` reads it, so a trial
26
+ * opened on a subject is running that subject from its first frame. Keys
27
+ * whose value is `undefined` are left to the default. */
28
+ config?: Partial<TC>;
29
+ }
30
+
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. */
33
+ function seedConfig<TC>(defaults: TC, seed: Partial<TC> | undefined): TC {
34
+ return seed ? fillConfigDefaults(seed, defaults) : defaults;
35
+ }
36
+
37
+ /** Append a new trial running `instrumentName`, at that instrument's default
38
+ * config — with `options.config` written over it — and the initial state
39
+ * that config produces. */
24
40
  export function addTrial(
25
41
  trials: TrialRecord[],
26
42
  instruments: InstrumentList,
27
43
  instrumentName: string,
44
+ options: AddTrialOptions = {},
28
45
  ): TrialRecord[] {
29
46
  const instrument = findInstrument(instruments, instrumentName);
30
- const config = instrument.defaultConfig();
47
+ const config = seedConfig(instrument.defaultConfig(), options.config);
31
48
  const state = instrument.initialState(config);
32
49
  const record: TrialRecord = {
33
50
  id: crypto.randomUUID(),
@@ -37,6 +54,7 @@ export function addTrial(
37
54
  view: initialView(instrument),
38
55
  undoStack: { past: [], future: [] },
39
56
  };
57
+ if (options.config) record.configSeed = options.config;
40
58
  return [...trials, record];
41
59
  }
42
60
 
@@ -65,8 +83,8 @@ export function closeTrial(trials: TrialRecord[], id: string): TrialRecord[] {
65
83
  return next.length === trials.length ? trials : next;
66
84
  }
67
85
 
68
- /** Return a trial to its instrument's defaults, keeping its id and its
69
- * place in the list. */
86
+ /** Return a trial to what it opened on — its instrument's defaults under the
87
+ * seed `addTrial` was given — keeping its id and its place in the list. */
70
88
  export function resetTrial(
71
89
  trials: TrialRecord[],
72
90
  id: string,
@@ -76,7 +94,7 @@ export function resetTrial(
76
94
  const current = trials[idx];
77
95
  if (!current) return trials;
78
96
  const instrument = findInstrument(instruments, current.instrumentName);
79
- const config = instrument.defaultConfig();
97
+ const config = seedConfig(instrument.defaultConfig(), current.configSeed);
80
98
  const state = instrument.initialState(config);
81
99
  const reset: TrialRecord = {
82
100
  ...current,