@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
@@ -1,5 +1,12 @@
1
- import { useVisibleRaf } from '@weasel-js/core';
2
- import { type RefObject, useCallback, useEffect, useRef, useState } from 'react';
1
+ import { openPointerSession, type PointerSession, useVisibleRaf } from '@weasel-js/core';
2
+ import {
3
+ type PointerEvent as ReactPointerEvent,
4
+ type RefObject,
5
+ useCallback,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
3
10
  import { screenToWorld } from '../canvas/canvasCoords';
4
11
  import { resolveFrame, type WorldSpec } from '../canvas/worldSpec';
5
12
  import type {
@@ -32,7 +39,7 @@ export interface UseDragDropArgs<TS, TC> {
32
39
 
33
40
  export interface UseDragDropResult {
34
41
  drag: DragState | null;
35
- startDrag: (item: PaletteItem, originScreenPos: Point) => void;
42
+ startDrag: (item: PaletteItem, e: ReactPointerEvent) => void;
36
43
  }
37
44
 
38
45
  export function useDragDrop<TS, TC>({
@@ -51,6 +58,12 @@ export function useDragDrop<TS, TC>({
51
58
  /** The move the next frame will resolve. Doubles as the throttle's flag:
52
59
  * non-null means a frame is already queued. */
53
60
  const pendingPos = useRef<Point | null>(null);
61
+ const sessionRef = useRef<PointerSession | null>(null);
62
+
63
+ /** A session keeps the closure it was opened with, so everything the drop
64
+ * reads has to come from here rather than from that closure. */
65
+ const liveRef = useRef({ capability, state, config, setState, emit, view, worldSpec });
66
+ liveRef.current = { capability, state, config, setState, emit, view, worldSpec };
54
67
 
55
68
  const isOverCanvas = useCallback(
56
69
  (screenPos: Point): boolean => {
@@ -72,10 +85,14 @@ export function useDragDrop<TS, TC>({
72
85
  const el = canvasContainerRef.current;
73
86
  if (!el) return null;
74
87
  const r = el.getBoundingClientRect();
75
- const frame = resolveFrame(worldSpec, { width: r.width, height: r.height });
76
- return screenToWorld({ x: screenPos.x - r.left, y: screenPos.y - r.top }, view, frame);
88
+ const frame = resolveFrame(liveRef.current.worldSpec, { width: r.width, height: r.height });
89
+ return screenToWorld(
90
+ { x: screenPos.x - r.left, y: screenPos.y - r.top },
91
+ liveRef.current.view,
92
+ frame,
93
+ );
77
94
  },
78
- [canvasContainerRef, view, worldSpec],
95
+ [canvasContainerRef],
79
96
  );
80
97
 
81
98
  const frameLoop = useVisibleRaf(() => {
@@ -83,68 +100,64 @@ export function useDragDrop<TS, TC>({
83
100
  pendingPos.current = null;
84
101
  const active = dragRef.current;
85
102
  if (!active || !screenPos) return;
103
+ const live = liveRef.current;
86
104
  let feedback: DragFeedback | null = null;
87
- if (capability.onDragOver && isOverCanvas(screenPos)) {
105
+ if (live.capability.onDragOver && isOverCanvas(screenPos)) {
88
106
  const world = screenToWorldFromContainer(screenPos);
89
- if (world) feedback = capability.onDragOver(world, active.item, state, config);
107
+ if (world) feedback = live.capability.onDragOver(world, active.item, live.state, live.config);
90
108
  }
91
109
  setDrag({ ...active, screenPos, feedback });
92
110
  });
93
111
 
94
- useEffect(() => {
95
- if (!drag) return;
96
- const handleMove = (e: PointerEvent) => {
97
- const screenPos = { x: e.clientX, y: e.clientY };
98
- const current = dragRef.current;
99
- if (!current) return;
100
- if (pendingPos.current !== null) {
101
- // A frame is already queued: keep the ghost under the cursor and let
102
- // that frame probe the drop target from the newest position.
103
- pendingPos.current = screenPos;
104
- dragRef.current = { ...current, screenPos };
105
- setDrag(dragRef.current);
106
- return;
107
- }
108
- pendingPos.current = screenPos;
109
- frameLoop.request();
110
- };
111
- const handleUp = (e: PointerEvent) => {
112
- const screenPos = { x: e.clientX, y: e.clientY };
113
- const active = dragRef.current;
114
- if (!active) return;
115
- pendingPos.current = null;
116
- frameLoop.cancel();
117
- if (isOverCanvas(screenPos)) {
118
- const world = screenToWorldFromContainer(screenPos);
119
- if (world) {
120
- const nextState = capability.onDrop(world, active.item, state, config);
121
- setState(nextState);
122
- emit('canvas.itemAdded');
123
- }
124
- }
125
- setDrag(null);
126
- };
127
- window.addEventListener('pointermove', handleMove);
128
- window.addEventListener('pointerup', handleUp);
129
- return () => {
130
- window.removeEventListener('pointermove', handleMove);
131
- window.removeEventListener('pointerup', handleUp);
132
- };
133
- }, [
134
- drag,
135
- capability,
136
- state,
137
- config,
138
- setState,
139
- emit,
140
- frameLoop,
141
- isOverCanvas,
142
- screenToWorldFromContainer,
143
- ]);
112
+ const clearDrag = useCallback(() => {
113
+ pendingPos.current = null;
114
+ frameLoop.cancel();
115
+ sessionRef.current = null;
116
+ dragRef.current = null;
117
+ setDrag(null);
118
+ }, [frameLoop]);
119
+
120
+ const startDrag = useCallback(
121
+ (item: PaletteItem, e: ReactPointerEvent) => {
122
+ if (sessionRef.current) return;
123
+ const next: DragState = { item, screenPos: { x: e.clientX, y: e.clientY }, feedback: null };
124
+ dragRef.current = next;
125
+ setDrag(next);
126
+ sessionRef.current = openPointerSession(e.currentTarget, e, {
127
+ onMove: (ev) => {
128
+ const screenPos = { x: ev.clientX, y: ev.clientY };
129
+ const current = dragRef.current;
130
+ if (!current) return;
131
+ if (pendingPos.current !== null) {
132
+ // A frame is already queued: keep the ghost under the cursor and
133
+ // let that frame probe the drop target from the newest position.
134
+ pendingPos.current = screenPos;
135
+ dragRef.current = { ...current, screenPos };
136
+ setDrag(dragRef.current);
137
+ return;
138
+ }
139
+ pendingPos.current = screenPos;
140
+ frameLoop.request();
141
+ },
142
+ onEnd: (ev) => {
143
+ const screenPos = { x: ev.clientX, y: ev.clientY };
144
+ const active = dragRef.current;
145
+ clearDrag();
146
+ if (!active || !isOverCanvas(screenPos)) return;
147
+ const world = screenToWorldFromContainer(screenPos);
148
+ if (!world) return;
149
+ const live = liveRef.current;
150
+ live.setState(live.capability.onDrop(world, active.item, live.state, live.config));
151
+ live.emit('canvas.itemAdded');
152
+ },
153
+ // An interrupted gesture never named a destination, so it drops nothing.
154
+ onCancel: clearDrag,
155
+ });
156
+ },
157
+ [clearDrag, frameLoop, isOverCanvas, screenToWorldFromContainer],
158
+ );
144
159
 
145
- const startDrag = useCallback((item: PaletteItem, originScreenPos: Point) => {
146
- setDrag({ item, screenPos: originScreenPos, feedback: null });
147
- }, []);
160
+ useEffect(() => () => sessionRef.current?.cancel(), []);
148
161
 
149
162
  return { drag, startDrag };
150
163
  }
@@ -1,8 +1,9 @@
1
- import type { PaletteItem, Point } from '../instrument/types';
1
+ import type { PointerEvent as ReactPointerEvent } from 'react';
2
+ import type { PaletteItem } from '../instrument/types';
2
3
 
3
4
  export interface PaletteProps {
4
5
  items: PaletteItem[];
5
- onDragStart: (item: PaletteItem, originScreenPos: Point) => void;
6
+ onDragStart: (item: PaletteItem, e: ReactPointerEvent) => void;
6
7
  className?: string;
7
8
  }
8
9
 
@@ -16,7 +17,7 @@ export function Palette({ items, onDragStart, className }: PaletteProps) {
16
17
  className="lk-palette__item"
17
18
  onPointerDown={(e) => {
18
19
  e.preventDefault();
19
- onDragStart(item, { x: e.clientX, y: e.clientY });
20
+ onDragStart(item, e);
20
21
  }}
21
22
  >
22
23
  <span className="lk-palette__label">{item.label}</span>
@@ -83,19 +83,23 @@ afterEach(() => {
83
83
  });
84
84
 
85
85
  function pointerDown(el: Element, x: number, y: number) {
86
- fireEvent.pointerDown(el, { clientX: x, clientY: y });
86
+ fireEvent.pointerDown(el, { pointerId: 1, buttons: 1, clientX: x, clientY: y });
87
87
  }
88
88
 
89
- function windowPointerMove(x: number, y: number) {
89
+ /** The session listens on the document, so that is where the rest of the
90
+ * gesture has to arrive — a dispatch on `window` reaches nothing. */
91
+ function dispatchOnDocument(type: string, init: PointerEventInit) {
90
92
  act(() => {
91
- window.dispatchEvent(new MouseEvent('pointermove', { clientX: x, clientY: y, bubbles: true }));
93
+ document.dispatchEvent(new PointerEvent(type, { pointerId: 1, bubbles: true, ...init }));
92
94
  });
93
95
  }
94
96
 
95
- function windowPointerUp(x: number, y: number) {
96
- act(() => {
97
- window.dispatchEvent(new MouseEvent('pointerup', { clientX: x, clientY: y, bubbles: true }));
98
- });
97
+ function pointerMove(x: number, y: number) {
98
+ dispatchOnDocument('pointermove', { buttons: 1, clientX: x, clientY: y });
99
+ }
100
+
101
+ function pointerUp(x: number, y: number) {
102
+ dispatchOnDocument('pointerup', { clientX: x, clientY: y });
99
103
  }
100
104
 
101
105
  describe('DragDrop integration', () => {
@@ -114,7 +118,7 @@ describe('DragDrop integration', () => {
114
118
  act(() => {
115
119
  pointerDown(palette, 0, 0);
116
120
  });
117
- windowPointerUp(250, 250);
121
+ pointerUp(250, 250);
118
122
 
119
123
  expect(latest.items).toHaveLength(1);
120
124
  expect(latest.items[0]?.id).toBe('a');
@@ -130,8 +134,8 @@ describe('DragDrop integration', () => {
130
134
  act(() => {
131
135
  pointerDown(palette, 0, 0);
132
136
  });
133
- windowPointerMove(10, 10);
134
- windowPointerUp(10, 10);
137
+ pointerMove(10, 10);
138
+ pointerUp(10, 10);
135
139
 
136
140
  expect(latest.items).toHaveLength(0);
137
141
  expect(document.querySelector('.lk-drag-ghost')).toBeNull();
@@ -146,7 +150,26 @@ describe('DragDrop integration', () => {
146
150
  });
147
151
  expect(document.querySelector('.lk-drag-ghost')).not.toBeNull();
148
152
 
149
- windowPointerUp(250, 250);
153
+ pointerUp(250, 250);
154
+ expect(document.querySelector('.lk-drag-ghost')).toBeNull();
155
+ });
156
+
157
+ it('a cancelled gesture drops nothing and clears the ghost', () => {
158
+ let latest: DropState = { items: [] };
159
+ renderLab((s) => {
160
+ latest = s;
161
+ });
162
+ const palette = screen.getByRole('button', { name: 'Item A' });
163
+
164
+ act(() => {
165
+ pointerDown(palette, 0, 0);
166
+ });
167
+ pointerMove(250, 250);
168
+ expect(document.querySelector('.lk-drag-ghost')).not.toBeNull();
169
+
170
+ dispatchOnDocument('pointercancel', {});
171
+
172
+ expect(latest.items).toHaveLength(0);
150
173
  expect(document.querySelector('.lk-drag-ghost')).toBeNull();
151
174
  });
152
175
 
@@ -162,7 +185,7 @@ describe('DragDrop integration', () => {
162
185
  });
163
186
  // The host spans (100,100)-(500,500), so its centre is at (300,300).
164
187
  // Dropping 50px right of centre and 50px above it, at zoom 1, y up.
165
- windowPointerUp(350, 250);
188
+ pointerUp(350, 250);
166
189
 
167
190
  expect(latest.items[0]).toMatchObject({ x: 50, y: 50 });
168
191
  });
package/src/index.ts CHANGED
@@ -145,25 +145,40 @@ export {
145
145
  zoomAt,
146
146
  } from './canvas';
147
147
  export * from './chrome';
148
- export { f } from './config/builder';
148
+ export { f, isConfigBranch } from './config/builder';
149
149
  export { fromConfigFields } from './config/fromConfigField';
150
+ export {
151
+ fillConfigDefaults,
152
+ hasConfigPath,
153
+ schemaNodeAtPath,
154
+ valueAtPath,
155
+ withValueAtPath,
156
+ } from './config/path';
150
157
  export { resolveConfigSchema } from './config/resolve';
151
158
  export { applyRules, builtinRules, titleCase } from './config/rules';
152
159
  export type {
153
160
  Annotations,
161
+ BranchAnnotations,
162
+ BranchOptions,
163
+ ConfigBranch,
164
+ ConfigEntry,
154
165
  ConfigNode,
155
166
  ConfigOf,
156
167
  ConfigOption,
168
+ ConfigPath,
157
169
  ConfigRule,
158
170
  ConfigRuleContext,
159
171
  ConfigSchema,
172
+ ConfigShape,
160
173
  ControlRenderer,
174
+ EntryValue,
161
175
  InferConfig,
162
176
  LeafPatch,
163
177
  NodeOptions,
164
178
  NodeValue,
165
179
  ResolvedConfig,
166
180
  SectionSpec,
181
+ ValueAtPath,
167
182
  } from './config/types';
168
183
  export { useConfigSchema } from './config/useConfigSchema';
169
184
  export { isLeafVisible } from './config/visible';
@@ -202,7 +217,7 @@ export type { ValidationResult } from './instrument/validateConfigSchema';
202
217
  export { validateConfigSchema } from './instrument/validateConfigSchema';
203
218
  export * from './job';
204
219
  export * from './lab';
205
- export type { LayerListProps } from './layers';
220
+ export type { LayerListProps, LayerTreeNode } from './layers';
206
221
  export { LayerList } from './layers';
207
222
  export * from './loupe';
208
223
  export * from './primitives';
@@ -230,6 +245,7 @@ export type {
230
245
  SavedSnapshot,
231
246
  SerializedTrial,
232
247
  StorageAdapter,
248
+ TrialInfo,
233
249
  TrialRecord,
234
250
  TrialStateHandle,
235
251
  UndoStack,
@@ -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,10 +2,11 @@ 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';
9
+ import type { TrialInfo } from '../state/types';
9
10
  import type { ToolCapability } from '../tools/types';
10
11
 
11
12
  /** What an instrument's `render` is handed: its state and config, the setters
@@ -14,12 +15,12 @@ export interface RenderContext<TS = unknown, TC = unknown> {
14
15
  state: TS;
15
16
  config: TC;
16
17
  setState: (next: TS | ((prev: TS) => TS)) => void;
17
- setConfig: (key: keyof TC, value: unknown) => void;
18
- trial: {
19
- id: string;
20
- /** The trial's view, in whatever shape this instrument chose. labkit persists
21
- * it and restores it on Reset without ever reading into it. */
22
- view: unknown;
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;
21
+ /** labkit persists `view` and restores it on Reset without ever reading
22
+ * into it. */
23
+ trial: TrialInfo & {
23
24
  setView: (next: unknown) => void;
24
25
  /** 2D convenience over `view`. Reads 1 and writes nothing when the trial holds
25
26
  * a view that is not the 2D one. */
@@ -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
 
@@ -52,6 +54,13 @@ describe('<Lab>', () => {
52
54
  expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(2);
53
55
  });
54
56
 
57
+ it('addTrial opens each trial on the config it was given', () => {
58
+ mountLab();
59
+ act(() => labRef?.addTrial('Stub', { config: { count: 7 } }));
60
+ expect(labRef?.trials.map((t) => (t.config as { count: number }).count)).toEqual([0, 7]);
61
+ expect(labRef?.trials.map((t) => (t.state as { value: number }).value)).toEqual([0, 7]);
62
+ });
63
+
55
64
  it('closeTrial removes one when more than one exists', () => {
56
65
  mountLab();
57
66
  act(() => labRef?.addTrial('Stub'));
@@ -128,4 +137,30 @@ describe('<Lab>', () => {
128
137
  /Unknown instrument/,
129
138
  );
130
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
+ });
131
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];
@@ -220,8 +229,8 @@ export function Lab({
220
229
  return {
221
230
  instruments,
222
231
  trials,
223
- addTrial: (instrumentName) => {
224
- const next = addTrialOp(store.getState().trials, instruments, instrumentName);
232
+ addTrial: (instrumentName, options) => {
233
+ const next = addTrialOp(store.getState().trials, instruments, instrumentName, options);
225
234
  replaceTrials(next);
226
235
  },
227
236
  cloneTrial: (id) => {
@@ -2,6 +2,7 @@ import { createContext, useContext } from 'react';
2
2
  import type { ConfigRule, ControlRenderer } from '../config/types';
3
3
  import type { InstrumentList } from '../instrument/types';
4
4
  import type { LabMode, SavedSnapshot, TrialRecord } from '../state/types';
5
+ import type { AddTrialOptions } from '../trial/trialOps';
5
6
 
6
7
  /** Lab-wide state and commands: the available instruments, the open
7
8
  * trials and the operations over them, saved snapshots, and the color
@@ -9,7 +10,10 @@ import type { LabMode, SavedSnapshot, TrialRecord } from '../state/types';
9
10
  export interface LabContextValue {
10
11
  instruments: InstrumentList;
11
12
  trials: TrialRecord[];
12
- addTrial: (instrumentName: string) => void;
13
+ /** Open a trial running `instrumentName`. `options.config` is written over
14
+ * that instrument's defaults, which is where a trial opened on a subject
15
+ * says which subject. */
16
+ addTrial: (instrumentName: string, options?: AddTrialOptions) => void;
13
17
  cloneTrial: (id: string) => void;
14
18
  closeTrial: (id: string) => void;
15
19
  resetTrial: (id: string) => void;
@@ -14,7 +14,10 @@
14
14
 
15
15
  .lk-shell-header {
16
16
  display: flex;
17
- align-items: center;
17
+ // Baseline, not center: the title is `--wzl-font-size-xl` and the controls
18
+ // beside it are not, so centering them leaves every label sitting off the
19
+ // title's baseline by half the difference in leading.
20
+ align-items: baseline;
18
21
  gap: var(--wzl-space-md);
19
22
  padding: var(--wzl-space-sm) var(--wzl-space-md);
20
23
  background: var(--wzl-surface-raised);
@@ -32,6 +35,9 @@
32
35
  .lk-shell-header-actions {
33
36
  margin-left: auto;
34
37
  display: flex;
38
+ // A flex container reports its first item's baseline, so this is what
39
+ // carries the row's baseline up to the header.
40
+ align-items: baseline;
35
41
  gap: var(--wzl-space-sm);
36
42
  }
37
43
 
@@ -73,12 +79,21 @@
73
79
  font-size: var(--wzl-font-size-sm);
74
80
  }
75
81
 
82
+ // A flex container reports its *first* item's baseline, and the leading icon
83
+ // is an <svg> with none — so a centered button hands the header a baseline
84
+ // synthesized from the icon's bottom edge, and the label lands 2px off the
85
+ // title. Baseline here makes the label the button's baseline; the icon keeps
86
+ // its own centering through `align-self`.
76
87
  .lk-lab-header__add {
77
88
  display: inline-flex;
78
- align-items: center;
89
+ align-items: baseline;
79
90
  gap: var(--wzl-space-xs);
80
91
  }
81
92
 
93
+ .lk-lab-header__add > svg {
94
+ align-self: center;
95
+ }
96
+
82
97
  // `Select` is a form field and defaults to `width: 100%`. Left to resolve that
83
98
  // against a shrink-to-fit header row it swallows the slack, which pushed the
84
99
  // consumer's own header content down to min-content and wrapped the row to
@@ -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>}