@weasel-js/labkit 1.4.0 → 1.4.2

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 (133) hide show
  1. package/README.md +8 -0
  2. package/dist/_dts/{CanvasStackContext-D6M0o8cQ.d.ts → CanvasStackContext-kjILVnPj.d.ts} +1 -1
  3. package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
  4. package/dist/_dts/{frac-QS7XzvpG.d.ts → frac-A4R6v4ld.d.ts} +36 -13
  5. package/dist/_dts/{index-B2X21G1c.d.ts → index-CsU9WhjM.d.ts} +90 -30
  6. package/dist/_dts/{types-ChVJJHvk.d.ts → types-BbXpvQa8.d.ts} +20 -1
  7. package/dist/_dts/{types-C1Zw7s5P.d.ts → types-lg4TSCb2.d.ts} +2 -1
  8. package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-DKjqpv20.d.ts} +5 -1
  9. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +5041 -0
  10. package/dist/canvas/index.d.ts +16 -16
  11. package/dist/canvas/index.js +2 -2
  12. package/dist/chrome/index.d.ts +15 -9
  13. package/dist/chrome/index.js +5 -5
  14. package/dist/{chunk-7VW3S44V.js → chunk-54ZWZ5FQ.js} +908 -301
  15. package/dist/chunk-54ZWZ5FQ.js.map +1 -0
  16. package/dist/{chunk-Y2HK44TG.js → chunk-D5KQ5OY6.js} +3 -3
  17. package/dist/{chunk-Y2HK44TG.js.map → chunk-D5KQ5OY6.js.map} +1 -1
  18. package/dist/{chunk-VHFQKAUL.js → chunk-FJG4PHTL.js} +83 -75
  19. package/dist/chunk-FJG4PHTL.js.map +1 -0
  20. package/dist/{chunk-LN6JDUGB.js → chunk-HXXTULDN.js} +2 -2
  21. package/dist/{chunk-LN6JDUGB.js.map → chunk-HXXTULDN.js.map} +1 -1
  22. package/dist/{chunk-L5WBGUVR.js → chunk-ISSVF5PT.js} +2753 -2654
  23. package/dist/chunk-ISSVF5PT.js.map +1 -0
  24. package/dist/chunk-NSOVI3AZ.js +170 -0
  25. package/dist/chunk-NSOVI3AZ.js.map +1 -0
  26. package/dist/{chunk-YQGQUBKA.js → chunk-TN7YSJVU.js} +62 -60
  27. package/dist/chunk-TN7YSJVU.js.map +1 -0
  28. package/dist/{chunk-4AMN4MMB.js → chunk-UDXOYZEC.js} +53 -39
  29. package/dist/chunk-UDXOYZEC.js.map +1 -0
  30. package/dist/{chunk-LJSIUFYD.js → chunk-ULDW42CR.js} +23 -2
  31. package/dist/chunk-ULDW42CR.js.map +1 -0
  32. package/dist/{chunk-QR5V3AFG.js → chunk-UTOEDPNU.js} +5 -5
  33. package/dist/{chunk-QR5V3AFG.js.map → chunk-UTOEDPNU.js.map} +1 -1
  34. package/dist/{chunk-UD7TEHZT.js → chunk-W2FJR5FF.js} +35 -22
  35. package/dist/chunk-W2FJR5FF.js.map +1 -0
  36. package/dist/{chunk-B5VWJBWM.js → chunk-WS6ZRV75.js} +3 -3
  37. package/dist/{chunk-B5VWJBWM.js.map → chunk-WS6ZRV75.js.map} +1 -1
  38. package/dist/{chunk-W3KSUXXR.js → chunk-XKENZTNE.js} +45 -14
  39. package/dist/chunk-XKENZTNE.js.map +1 -0
  40. package/dist/controls/index.d.ts +4 -3
  41. package/dist/controls/index.js +3 -3
  42. package/dist/dragdrop/index.d.ts +8 -8
  43. package/dist/dragdrop/index.js +2 -2
  44. package/dist/index.d.ts +75 -110
  45. package/dist/index.js +148 -115
  46. package/dist/index.js.map +1 -1
  47. package/dist/job/index.js +1 -1
  48. package/dist/layers/index.d.ts +26 -11
  49. package/dist/layers/index.js +3 -3
  50. package/dist/loupe/index.d.ts +7 -13
  51. package/dist/loupe/index.js +2 -2
  52. package/dist/passthrough/weasel-canvas.d.ts +3 -343
  53. package/dist/passthrough/weasel-canvas.js +1 -1
  54. package/dist/passthrough/weasel-ui.d.ts +123 -3088
  55. package/dist/passthrough/weasel-ui.js +2 -2
  56. package/dist/primitives/index.js +4 -4
  57. package/dist/state/index.d.ts +3 -3
  58. package/dist/state/index.js +2 -2
  59. package/dist/styles.css +30 -3
  60. package/dist/surface/index.js +2 -2
  61. package/dist/ui/layers/index.d.ts +22 -9
  62. package/dist/ui/layers/index.js +2 -2
  63. package/dist/undo/index.d.ts +6 -5
  64. package/package.json +8 -8
  65. package/src/annotations/AnnotationTargets.tsx +6 -1
  66. package/src/annotations/Annotations.overlay.test.tsx +2 -0
  67. package/src/annotations/types.ts +4 -1
  68. package/src/canvas/CanvasStack.tsx +8 -13
  69. package/src/canvas/useOrbit.test.ts +97 -1
  70. package/src/canvas/useOrbit.ts +41 -34
  71. package/src/canvas/usePanZoom.test.ts +106 -1
  72. package/src/canvas/usePanZoom.ts +54 -36
  73. package/src/chrome/ChromeRegions.stories.tsx +42 -1
  74. package/src/chrome/builtins.test.ts +4 -0
  75. package/src/chrome/builtins.tsx +18 -0
  76. package/src/chrome/index.ts +1 -1
  77. package/src/chrome/regions/SidebarRegion.tsx +7 -6
  78. package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
  79. package/src/chrome/regions/TitleBarRegion.tsx +16 -7
  80. package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
  81. package/src/chrome/regions/ToolbarRegion.tsx +2 -2
  82. package/src/chrome/regions/ViewportRegion.tsx +2 -2
  83. package/src/chrome/regions/regions.test.tsx +45 -1
  84. package/src/chrome/types.ts +21 -3
  85. package/src/controls/ControlPanel.test.tsx +38 -0
  86. package/src/controls/ControlPanel.tsx +51 -6
  87. package/src/dragdrop/DragDropRuntime.tsx +74 -61
  88. package/src/dragdrop/Palette.tsx +4 -3
  89. package/src/dragdrop/dragDrop.test.tsx +35 -12
  90. package/src/index.ts +2 -1
  91. package/src/instrument/types.ts +4 -5
  92. package/src/job/useJob.ts +1 -1
  93. package/src/lab/Lab.test.tsx +7 -0
  94. package/src/lab/Lab.tsx +2 -2
  95. package/src/lab/LabContext.ts +5 -1
  96. package/src/lab/LabShell.less +17 -2
  97. package/src/lab/Workspace.tsx +1 -1
  98. package/src/layers/AGENTS.md +29 -7
  99. package/src/layers/LayerList.less +16 -0
  100. package/src/layers/LayerList.stories.tsx +66 -0
  101. package/src/layers/LayerList.test.tsx +185 -2
  102. package/src/layers/LayerList.tsx +221 -74
  103. package/src/layers/index.ts +1 -1
  104. package/src/passthrough/weasel-ui.ts +5 -0
  105. package/src/primitives/FloatingPanel.test.tsx +87 -0
  106. package/src/primitives/FloatingPanel.tsx +59 -41
  107. package/src/state/store.test.ts +78 -0
  108. package/src/state/store.ts +27 -0
  109. package/src/state/types.ts +20 -0
  110. package/src/trial/Trial.less +4 -0
  111. package/src/trial/Trial.targets.test.tsx +96 -0
  112. package/src/trial/Trial.test.tsx +171 -14
  113. package/src/trial/Trial.tsx +4 -1
  114. package/src/trial/TrialChrome.tsx +22 -2
  115. package/src/trial/TrialTitleBar.tsx +7 -3
  116. package/src/trial/index.ts +1 -0
  117. package/src/trial/trialOps.test.ts +49 -1
  118. package/src/trial/trialOps.ts +28 -6
  119. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +0 -564
  120. package/dist/_dts/PrefsForm-BkUJZx0A.d.ts +0 -204
  121. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +0 -21
  122. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +0 -87
  123. package/dist/_dts/types-C-gh9Ap-.d.ts +0 -695
  124. package/dist/chunk-4AMN4MMB.js.map +0 -1
  125. package/dist/chunk-7VW3S44V.js.map +0 -1
  126. package/dist/chunk-L5WBGUVR.js.map +0 -1
  127. package/dist/chunk-LJSIUFYD.js.map +0 -1
  128. package/dist/chunk-UD7TEHZT.js.map +0 -1
  129. package/dist/chunk-VHFQKAUL.js.map +0 -1
  130. package/dist/chunk-W3KSUXXR.js.map +0 -1
  131. package/dist/chunk-XCBCZEBO.js +0 -94
  132. package/dist/chunk-XCBCZEBO.js.map +0 -1
  133. package/dist/chunk-YQGQUBKA.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
@@ -202,7 +202,7 @@ export type { ValidationResult } from './instrument/validateConfigSchema';
202
202
  export { validateConfigSchema } from './instrument/validateConfigSchema';
203
203
  export * from './job';
204
204
  export * from './lab';
205
- export type { LayerListProps } from './layers';
205
+ export type { LayerListProps, LayerTreeNode } from './layers';
206
206
  export { LayerList } from './layers';
207
207
  export * from './loupe';
208
208
  export * from './primitives';
@@ -230,6 +230,7 @@ export type {
230
230
  SavedSnapshot,
231
231
  SerializedTrial,
232
232
  StorageAdapter,
233
+ TrialInfo,
233
234
  TrialRecord,
234
235
  TrialStateHandle,
235
236
  UndoStack,
@@ -6,6 +6,7 @@ import type { 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
@@ -15,11 +16,9 @@ export interface RenderContext<TS = unknown, TC = unknown> {
15
16
  config: TC;
16
17
  setState: (next: TS | ((prev: TS) => TS)) => void;
17
18
  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;
19
+ /** labkit persists `view` and restores it on Reset without ever reading
20
+ * into it. */
21
+ trial: TrialInfo & {
23
22
  setView: (next: unknown) => void;
24
23
  /** 2D convenience over `view`. Reads 1 and writes nothing when the trial holds
25
24
  * a view that is not the 2D one. */
package/src/job/useJob.ts CHANGED
@@ -111,7 +111,7 @@ export function useJob<TS, TC, TItem>({
111
111
  lastKey.current = key;
112
112
  started.current = true;
113
113
  start();
114
- }, [auto, key, start]); // eslint-disable-line react-hooks/exhaustive-deps
114
+ }, [auto, key, start]);
115
115
 
116
116
  useEffect(
117
117
  () => () => {
@@ -52,6 +52,13 @@ describe('<Lab>', () => {
52
52
  expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(2);
53
53
  });
54
54
 
55
+ it('addTrial opens each trial on the config it was given', () => {
56
+ mountLab();
57
+ act(() => labRef?.addTrial('Stub', { config: { count: 7 } }));
58
+ expect(labRef?.trials.map((t) => (t.config as { count: number }).count)).toEqual([0, 7]);
59
+ expect(labRef?.trials.map((t) => (t.state as { value: number }).value)).toEqual([0, 7]);
60
+ });
61
+
55
62
  it('closeTrial removes one when more than one exists', () => {
56
63
  mountLab();
57
64
  act(() => labRef?.addTrial('Stub'));
package/src/lab/Lab.tsx CHANGED
@@ -220,8 +220,8 @@ export function Lab({
220
220
  return {
221
221
  instruments,
222
222
  trials,
223
- addTrial: (instrumentName) => {
224
- const next = addTrialOp(store.getState().trials, instruments, instrumentName);
223
+ addTrial: (instrumentName, options) => {
224
+ const next = addTrialOp(store.getState().trials, instruments, instrumentName, options);
225
225
  replaceTrials(next);
226
226
  },
227
227
  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
@@ -210,7 +210,7 @@ export function Workspace({
210
210
  if (!surface) return;
211
211
  surface.invalidateRects();
212
212
  return store.events.on('node.placementChanged', () => surface.invalidateRects());
213
- }, [store, surface, nodeIds]); // eslint-disable-line react-hooks/exhaustive-deps
213
+ }, [store, surface, nodeIds]);
214
214
 
215
215
  useEffect(() => {
216
216
  if (!onLayoutChange) return;
@@ -6,22 +6,26 @@ The `src/layers/` directory implements the `<LayerList>` UI used by the `layers`
6
6
 
7
7
  | File | Role |
8
8
  |---|---|
9
- | `LayerList.tsx` | Layer toggle/reorder list (sidebar widget) |
10
- | `LayerList.less` | Row styling, drag handle, pinned-row variant |
9
+ | `LayerList.tsx` | Layer toggle/reorder list, flat or nested (sidebar widget) |
10
+ | `LayerList.less` | Row styling, drag handle, pinned-row variant, subtree indent |
11
+ | `LayerList.stories.tsx` | Flat and nested renderings |
11
12
 
12
13
  ## Props
13
14
 
14
15
  ```ts
15
16
  interface LayerListProps {
16
- layers: LayerDescriptor[]; // { id, label, alwaysOn? }
17
+ layers: LayerTreeNode[]; // LayerDescriptor + { children?, defaultCollapsed? }
17
18
  visibility: Record<string, boolean>; // missing key → visible
18
- onReorder: (newOrder: LayerDescriptor[]) => void;
19
+ onReorder: (newOrder: LayerTreeNode[]) => void;
19
20
  onToggle: (id: string, visible: boolean) => void;
21
+ collapsedIds?: readonly string[]; // supplying it makes collapse controlled
22
+ onCollapsedChange?: (ids: string[]) => void;
20
23
  className?: string;
21
24
  }
22
25
  ```
23
26
 
24
- `LayerDescriptor` lives in `src/instrument/types.ts`.
27
+ `LayerDescriptor` lives in `src/instrument/types.ts`; `LayerTreeNode` extends it
28
+ in `LayerList.tsx`, so a plain `LayerDescriptor[]` is still a valid `layers`.
25
29
 
26
30
  ## How it integrates
27
31
 
@@ -44,6 +48,24 @@ Setting `alwaysOn: true` on a `LayerDescriptor` does two things:
44
48
 
45
49
  This is intended for legend/HUD layers that should never be toggled off. Visibility for pinned rows is **not** read from the `visibility` prop; they always render.
46
50
 
51
+ ## Nesting
52
+
53
+ A node with `children` renders an expandable subtree. The expand control is
54
+ `<Disclosure>` from `@weasel-js/ui` — the same twisty every other collapsible
55
+ surface uses.
56
+
57
+ Reordering is scoped to siblings: a drag moves a row within its own parent's
58
+ child list and never reparents it. `onReorder` still receives the whole tree,
59
+ with only that sibling group's order changed.
60
+
61
+ The twisty column appears only when some node in the tree has children — a flat
62
+ list renders exactly the rows it always did. Within a tree, childless rows get
63
+ `.lk-layer-list__twisty-gap` so labels stay aligned down a level.
64
+
65
+ Collapse is uncontrolled by default, seeded once from each node's
66
+ `defaultCollapsed`. Pass `collapsedIds` to own it; `onCollapsedChange` fires
67
+ either way.
68
+
47
69
  ## Drag handle customization
48
70
 
49
71
  The drag handle is a button with class `lk-layer-list__handle` holding `<DragHandleGlyph>` (from `@weasel-js/ui`), the same grip `LayerStack` uses. Its padding is transparent and cancelled by an equal negative margin, so the grab target is larger than the drawn dots without widening the row. Row pitch — height plus row gap — is measured from the DOM when a drag starts, so restyling the row does not skew drag distance. It used to be a hardcoded `28`, which had already drifted from the rendered height by the time it was found.
@@ -59,7 +81,7 @@ If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No laye
59
81
  Fork this component if you need:
60
82
  - Multi-select reorder (current implementation is single-row)
61
83
  - Right-click context menu
62
- - Layer groups or nesting
84
+ - Reparenting by drag (nesting renders, but a drag stays within one parent)
63
85
  - Solo/mute UI common in DAW-style apps
64
86
 
65
- The implementation is small (~100 lines) and self-contained — only depends on `LayerDescriptor` from instrument types.
87
+ It is self-contained: `LayerDescriptor` from instrument types and `Disclosure`/`DragHandleGlyph` from `@weasel-js/ui`.
@@ -75,3 +75,19 @@
75
75
  .lk-layer-list input[type='checkbox'] {
76
76
  accent-color: var(--wzl-accent);
77
77
  }
78
+
79
+ .lk-layer-list__children {
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: 1px;
83
+ // Indents by the twisty column, so a child's own twisty starts where its
84
+ // parent's label does.
85
+ padding-left: var(--wzl-space-md);
86
+ }
87
+
88
+ // Holds the twisty column open on rows that have nothing to expand, so labels
89
+ // line up down the level.
90
+ .lk-layer-list__twisty-gap {
91
+ flex: 0 0 auto;
92
+ width: var(--wzl-disclosure-target, 20px);
93
+ }
@@ -0,0 +1,66 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { LayerList, type LayerTreeNode } from './LayerList';
4
+
5
+ const meta: Meta<typeof LayerList> = {
6
+ title: 'labkit/layers/LayerList',
7
+ component: LayerList,
8
+ };
9
+ export default meta;
10
+ type Story = StoryObj<typeof LayerList>;
11
+
12
+ const FLAT: LayerTreeNode[] = [
13
+ { id: 'plants', label: 'Plants' },
14
+ { id: 'grid', label: 'Grid' },
15
+ { id: 'legend', label: 'Legend', alwaysOn: true },
16
+ ];
17
+
18
+ const NESTED: LayerTreeNode[] = [
19
+ {
20
+ id: 'ingest',
21
+ label: 'Ingest',
22
+ children: [
23
+ { id: 'ingest.fetch', label: 'Fetch' },
24
+ { id: 'ingest.parse', label: 'Parse' },
25
+ ],
26
+ },
27
+ {
28
+ id: 'solve',
29
+ label: 'Solve',
30
+ defaultCollapsed: true,
31
+ children: [
32
+ { id: 'solve.seed', label: 'Seed' },
33
+ {
34
+ id: 'solve.relax',
35
+ label: 'Relax',
36
+ children: [
37
+ { id: 'solve.relax.sweep', label: 'Sweep' },
38
+ { id: 'solve.relax.check', label: 'Check' },
39
+ ],
40
+ },
41
+ ],
42
+ },
43
+ { id: 'report', label: 'Report', alwaysOn: true },
44
+ ];
45
+
46
+ function Demo({ seed }: { seed: LayerTreeNode[] }) {
47
+ const [layers, setLayers] = useState(seed);
48
+ const [visibility, setVisibility] = useState<Record<string, boolean>>({});
49
+ return (
50
+ <div style={{ width: 220 }}>
51
+ <LayerList
52
+ layers={layers}
53
+ visibility={visibility}
54
+ onReorder={setLayers}
55
+ onToggle={(id, visible) => setVisibility((v) => ({ ...v, [id]: visible }))}
56
+ />
57
+ </div>
58
+ );
59
+ }
60
+
61
+ /** No node has children, so no row spends width on a twisty column. */
62
+ export const Flat: Story = { render: () => <Demo seed={FLAT} /> };
63
+
64
+ /** Dragging a row reorders it among its own siblings — a drag never moves a
65
+ * layer to a different parent. */
66
+ export const Tree: Story = { render: () => <Demo seed={NESTED} /> };