@weasel-js/labkit 1.6.1 → 1.7.1

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 (256) hide show
  1. package/README.md +3 -3
  2. package/dist/_dts/{CanvasStackContext-Wo6OwIVM.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
  3. package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-D6t7o0pF.d.ts} +59 -18
  4. package/dist/_dts/{index-CKyX0ME_.d.ts → index-BTmaRy6a.d.ts} +10 -6
  5. package/dist/_dts/{types-B7yBTTF9.d.ts → index-DCMA4Fy1.d.ts} +59 -113
  6. package/dist/_dts/{types-DTuViBvc.d.ts → types-BUBSIZuV.d.ts} +18 -8
  7. package/dist/_dts/{useTrialState-CMaV6UD4.d.ts → useTrialState-Bh8bzFgE.d.ts} +1 -1
  8. package/dist/canvas/index.d.ts +8 -6
  9. package/dist/canvas/index.js +6 -6
  10. package/dist/chrome/index.d.ts +6 -4
  11. package/dist/chrome/index.js +11 -10
  12. package/dist/chunk-2ES4W47N.js +8 -0
  13. package/dist/chunk-2ES4W47N.js.map +1 -0
  14. package/dist/{chunk-WPLAANH4.js → chunk-2N7UBSS2.js} +12 -12
  15. package/dist/chunk-2N7UBSS2.js.map +1 -0
  16. package/dist/chunk-4KSC7NXL.js +15491 -0
  17. package/dist/chunk-4KSC7NXL.js.map +1 -0
  18. package/dist/{chunk-SLAQPUI4.js → chunk-CEGFXTBD.js} +10 -63
  19. package/dist/chunk-CEGFXTBD.js.map +1 -0
  20. package/dist/chunk-CZZPXUOU.js +3 -0
  21. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  22. package/dist/{chunk-QGMOA2YE.js → chunk-E23Q6A3Y.js} +296 -104
  23. package/dist/chunk-E23Q6A3Y.js.map +1 -0
  24. package/dist/{chunk-GHGO52SO.js → chunk-E6NARGU7.js} +5 -5
  25. package/dist/{chunk-GHGO52SO.js.map → chunk-E6NARGU7.js.map} +1 -1
  26. package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
  27. package/dist/chunk-EESJUSYR.js.map +1 -0
  28. package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
  29. package/dist/chunk-EFXRM6XQ.js.map +1 -0
  30. package/dist/chunk-F4ACIK7Z.js +210 -0
  31. package/dist/chunk-F4ACIK7Z.js.map +1 -0
  32. package/dist/{chunk-6FLXL3I7.js → chunk-FB4MIZOO.js} +77 -45
  33. package/dist/chunk-FB4MIZOO.js.map +1 -0
  34. package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
  35. package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
  36. package/dist/{chunk-4TT3PNPR.js → chunk-GAHELLJT.js} +95 -95
  37. package/dist/chunk-GAHELLJT.js.map +1 -0
  38. package/dist/{chunk-HKV7LRHY.js → chunk-IBVRMHW5.js} +5 -5
  39. package/dist/{chunk-HKV7LRHY.js.map → chunk-IBVRMHW5.js.map} +1 -1
  40. package/dist/{chunk-ASOK76ZH.js → chunk-JFOJESJF.js} +72 -29
  41. package/dist/chunk-JFOJESJF.js.map +1 -0
  42. package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
  43. package/dist/chunk-PMLZP7RT.js.map +1 -0
  44. package/dist/{chunk-TJ77A4A3.js → chunk-READTDQX.js} +5 -9
  45. package/dist/chunk-READTDQX.js.map +1 -0
  46. package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
  47. package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
  48. package/dist/{chunk-CGHF6RZV.js → chunk-T6DLGEJB.js} +18 -18
  49. package/dist/chunk-T6DLGEJB.js.map +1 -0
  50. package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
  51. package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
  52. package/dist/{chunk-DWMSB4CA.js → chunk-VYVGVBGY.js} +95 -32
  53. package/dist/chunk-VYVGVBGY.js.map +1 -0
  54. package/dist/{chunk-IRTGUQ5O.js → chunk-WLMDTI2Q.js} +16 -6
  55. package/dist/chunk-WLMDTI2Q.js.map +1 -0
  56. package/dist/{chunk-NEXR37YJ.js → chunk-XE5HJOPL.js} +6 -8
  57. package/dist/chunk-XE5HJOPL.js.map +1 -0
  58. package/dist/chunk-XVUKDSYV.js +38 -0
  59. package/dist/chunk-XVUKDSYV.js.map +1 -0
  60. package/dist/{chunk-O6FFFOHS.js → chunk-YCFKW5RX.js} +16 -8
  61. package/dist/chunk-YCFKW5RX.js.map +1 -0
  62. package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
  63. package/dist/chunk-ZQIHJXP4.js.map +1 -0
  64. package/dist/config/index.d.ts +28 -13
  65. package/dist/config/index.js +4 -4
  66. package/dist/controls/index.d.ts +4 -7
  67. package/dist/controls/index.js +6 -5
  68. package/dist/dragdrop/index.d.ts +23 -3
  69. package/dist/dragdrop/index.js +4 -3
  70. package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
  71. package/dist/index.d.ts +72 -487
  72. package/dist/index.js +603 -441
  73. package/dist/index.js.map +1 -1
  74. package/dist/job/index.d.ts +8 -0
  75. package/dist/job/index.js +1 -1
  76. package/dist/layers/index.d.ts +6 -5
  77. package/dist/layers/index.js +3 -2
  78. package/dist/loupe/index.d.ts +100 -30
  79. package/dist/loupe/index.js +585 -6
  80. package/dist/loupe/index.js.map +1 -1
  81. package/dist/overview/index.d.ts +4 -2
  82. package/dist/overview/index.js +20 -18
  83. package/dist/overview/index.js.map +1 -1
  84. package/dist/passthrough/weasel-canvas.d.ts +2 -0
  85. package/dist/passthrough/weasel-ui.d.ts +2 -2701
  86. package/dist/passthrough/weasel-ui.js +4 -3
  87. package/dist/primitives/index.d.ts +37 -2
  88. package/dist/primitives/index.js +9 -8
  89. package/dist/state/index.d.ts +7 -5
  90. package/dist/state/index.js +6 -6
  91. package/dist/styles.css +150 -126
  92. package/dist/surface/index.d.ts +10 -2
  93. package/dist/surface/index.js +1 -1
  94. package/dist/undo/index.d.ts +5 -3
  95. package/package.json +14 -13
  96. package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
  97. package/src/annotations/AnnotationOverlay.tsx +52 -33
  98. package/src/annotations/Annotations.less +0 -10
  99. package/src/annotations/Annotations.overlay.test.tsx +31 -0
  100. package/src/annotations/MarkList.tsx +4 -8
  101. package/src/annotations/capture.browser.test.ts +41 -0
  102. package/src/annotations/capture.ts +14 -7
  103. package/src/annotations/drawOne.ts +2 -2
  104. package/src/annotations/frac.test.ts +44 -1
  105. package/src/annotations/frac.ts +46 -18
  106. package/src/annotations/index.ts +1 -0
  107. package/src/annotations/paint.test.ts +76 -46
  108. package/src/annotations/paint.ts +65 -43
  109. package/src/annotations/store.test.ts +212 -2
  110. package/src/annotations/store.ts +130 -27
  111. package/src/annotations/svgNodes.test.ts +28 -19
  112. package/src/annotations/svgNodes.ts +6 -6
  113. package/src/annotations/types.ts +29 -10
  114. package/src/canvas/AGENTS.md +1 -0
  115. package/src/canvas/CameraInput.test.tsx +30 -0
  116. package/src/canvas/CameraInput.tsx +60 -48
  117. package/src/canvas/CanvasStack.tsx +11 -6
  118. package/src/canvas/CanvasStackContext.ts +4 -1
  119. package/src/canvas/LinkedCursor.less +1 -1
  120. package/src/canvas/Stage.test.tsx +33 -4
  121. package/src/canvas/Stage.tsx +9 -4
  122. package/src/canvas/cameraRegistry.test.ts +55 -0
  123. package/src/canvas/cameraRegistry.ts +87 -0
  124. package/src/canvas/cameraZoom.test.ts +46 -0
  125. package/src/canvas/cameraZoom.ts +24 -0
  126. package/src/canvas/index.ts +14 -2
  127. package/src/canvas/sharedScope.test.tsx +94 -0
  128. package/src/canvas/worldSpec.ts +3 -1
  129. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  130. package/src/chrome/builtins.test.ts +7 -6
  131. package/src/chrome/builtins.tsx +5 -1
  132. package/src/chrome/index.ts +1 -1
  133. package/src/chrome/merge.test.ts +5 -0
  134. package/src/chrome/merge.ts +4 -2
  135. package/src/chrome/regions/PaletteRegion.less +0 -7
  136. package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
  137. package/src/chrome/regions/PaletteRegion.tsx +3 -4
  138. package/src/chrome/regions/SidebarRegion.less +12 -2
  139. package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
  140. package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
  141. package/src/chrome/regions/ViewportRegion.less +6 -0
  142. package/src/chrome/regions/ViewportRegion.tsx +10 -4
  143. package/src/chrome/types.ts +4 -2
  144. package/src/config/auto.ts +1 -0
  145. package/src/config/builder.test.ts +2 -1
  146. package/src/config/builder.ts +18 -5
  147. package/src/config/fromConfigField.test.ts +14 -0
  148. package/src/config/fromConfigField.ts +18 -4
  149. package/src/config/path.ts +2 -5
  150. package/src/config/resolve.ts +4 -0
  151. package/src/config/types.ts +14 -9
  152. package/src/controls/ControlPanel.less +1 -1
  153. package/src/controls/ControlPanel.stories.tsx +3 -2
  154. package/src/controls/ControlPanel.test.tsx +98 -11
  155. package/src/controls/ControlPanel.tsx +61 -2
  156. package/src/dragdrop/DragDropRuntime.tsx +22 -13
  157. package/src/dragdrop/DragGhost.tsx +3 -0
  158. package/src/dragdrop/Palette.tsx +2 -0
  159. package/src/index.test.ts +5 -0
  160. package/src/index.ts +16 -2
  161. package/src/instrument/types.ts +0 -6
  162. package/src/job/useJob.ts +14 -9
  163. package/src/lab/Lab.tsx +88 -73
  164. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  165. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  166. package/src/lab/LabFullChrome.stories.tsx +1 -0
  167. package/src/lab/LabHeader.test.tsx +24 -14
  168. package/src/lab/LabHeader.tsx +11 -12
  169. package/src/lab/LabShell.less +9 -17
  170. package/src/lab/LabSwitcher.browser.test.tsx +27 -0
  171. package/src/lab/LabSwitcher.less +8 -6
  172. package/src/lab/LabZoom.test.tsx +223 -0
  173. package/src/lab/LabZoom.tsx +170 -0
  174. package/src/lab/Workspace.tsx +48 -52
  175. package/src/lab/index.ts +1 -0
  176. package/src/loupe/AGENTS.md +28 -15
  177. package/src/loupe/CanvasLoupe.tsx +2 -3
  178. package/src/loupe/DomLoupe.tsx +1 -7
  179. package/src/loupe/Loupe.less +16 -0
  180. package/src/loupe/LoupeGestures.tsx +1 -1
  181. package/src/loupe/TrialLoupe.tsx +120 -57
  182. package/src/loupe/index.ts +1 -6
  183. package/src/loupe/types.test.ts +3 -3
  184. package/src/loupe/types.ts +24 -27
  185. package/src/loupe/useLoupe.test.tsx +29 -4
  186. package/src/loupe/useLoupe.ts +20 -17
  187. package/src/overview/OverviewMarks.tsx +12 -4
  188. package/src/overview/TrialOverview.tsx +10 -8
  189. package/src/passthrough/weasel-ui.test.ts +2 -0
  190. package/src/passthrough/weasel-ui.ts +17 -5
  191. package/src/primitives/FloatingPanel.test.tsx +11 -0
  192. package/src/primitives/FloatingPanel.tsx +8 -7
  193. package/src/primitives/JobProgress.less +2 -1
  194. package/src/primitives/Legend.stories.tsx +1 -0
  195. package/src/primitives/Readout.browser.test.tsx +137 -0
  196. package/src/primitives/Readout.less +12 -0
  197. package/src/primitives/Readout.stories.tsx +58 -0
  198. package/src/primitives/Readout.test.tsx +93 -0
  199. package/src/primitives/Readout.tsx +56 -0
  200. package/src/primitives/Split.tsx +69 -41
  201. package/src/primitives/Toolbar.tsx +3 -3
  202. package/src/primitives/ZoomControl.less +1 -2
  203. package/src/primitives/index.ts +2 -0
  204. package/src/specimen/Specimen.stories.tsx +1 -0
  205. package/src/specimen/Specimen.tsx +2 -3
  206. package/src/state/Persistence.browser.test.tsx +47 -0
  207. package/src/state/store.test.ts +5 -1
  208. package/src/state/undock.ts +3 -0
  209. package/src/state/useOpenOnce.ts +20 -16
  210. package/src/state/usePersistedState.test.tsx +1 -1
  211. package/src/state/view.ts +1 -0
  212. package/src/styles.less +1 -0
  213. package/src/surface/index.ts +1 -1
  214. package/src/surface/useTiledSurface.test.tsx +14 -0
  215. package/src/surface/useTiledSurface.ts +11 -3
  216. package/src/theme/Interstellar.stories.tsx +3 -1
  217. package/src/theme/base.less +21 -47
  218. package/src/theme/interstellar.test.ts +1 -1
  219. package/src/theme/interstellar.theme.json +10 -3
  220. package/src/trial/Trial.annotations.test.tsx +73 -0
  221. package/src/trial/Trial.less +0 -29
  222. package/src/trial/Trial.loupe.test.tsx +28 -11
  223. package/src/trial/Trial.tsx +85 -67
  224. package/src/trial/TrialChrome.tsx +27 -6
  225. package/src/trial/index.ts +2 -0
  226. package/src/trial/loupeSwitch.ts +15 -0
  227. package/dist/_dts/LayerList.d-BoELAMsM.d.ts +0 -83
  228. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  229. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
  230. package/dist/_dts/Select.d-Cr8-zoup.d.ts +0 -569
  231. package/dist/chunk-4TT3PNPR.js.map +0 -1
  232. package/dist/chunk-6FLXL3I7.js.map +0 -1
  233. package/dist/chunk-ASOK76ZH.js.map +0 -1
  234. package/dist/chunk-CGHF6RZV.js.map +0 -1
  235. package/dist/chunk-CQLQPQ4P.js.map +0 -1
  236. package/dist/chunk-DWMSB4CA.js.map +0 -1
  237. package/dist/chunk-HXXTULDN.js.map +0 -1
  238. package/dist/chunk-IJJUK4FU.js.map +0 -1
  239. package/dist/chunk-IRTGUQ5O.js.map +0 -1
  240. package/dist/chunk-KHK5FGER.js +0 -145
  241. package/dist/chunk-KHK5FGER.js.map +0 -1
  242. package/dist/chunk-NEXR37YJ.js.map +0 -1
  243. package/dist/chunk-O6FFFOHS.js.map +0 -1
  244. package/dist/chunk-QGMOA2YE.js.map +0 -1
  245. package/dist/chunk-RWNDDKOH.js.map +0 -1
  246. package/dist/chunk-S4NCI32V.js +0 -549
  247. package/dist/chunk-S4NCI32V.js.map +0 -1
  248. package/dist/chunk-SBXNQW4G.js +0 -3
  249. package/dist/chunk-SLAQPUI4.js.map +0 -1
  250. package/dist/chunk-TJ77A4A3.js.map +0 -1
  251. package/dist/chunk-TYN6MLXX.js +0 -13864
  252. package/dist/chunk-TYN6MLXX.js.map +0 -1
  253. package/dist/chunk-WPLAANH4.js.map +0 -1
  254. package/src/primitives/useRovingTabIndex.test.ts +0 -48
  255. package/src/primitives/useRovingTabIndex.ts +0 -88
  256. package/src/state/Persistence.stories.tsx +0 -75
@@ -1,4 +1,5 @@
1
- import { openPointerSession, type PointerSession, useVisibleRaf } from '@weasel-js/core';
1
+ import { openPointerSession, type PointerSession, useLatest, useVisibleRaf } from '@weasel-js/core';
2
+ import { boxContainsPoint } from '@weasel-js/geom';
2
3
  import {
3
4
  type PointerEvent as ReactPointerEvent,
4
5
  type RefObject,
@@ -18,12 +19,16 @@ import type {
18
19
  } from '../instrument/types';
19
20
  import { DragGhost } from './DragGhost';
20
21
 
22
+ /** A palette drag in progress. `screenPos` is in client pixels; `feedback` is
23
+ * the last `onDragOver` answer — `null` off the canvas or without one. */
21
24
  export interface DragState {
22
25
  item: PaletteItem;
23
26
  screenPos: Point;
24
27
  feedback: DragFeedback | null;
25
28
  }
26
29
 
30
+ /** Inputs to `useDragDrop`: the capability, the element that counts as the
31
+ * drop target, and the trial state a drop rewrites. */
27
32
  export interface UseDragDropArgs<TS, TC> {
28
33
  capability: DragDropCapability<TS, TC>;
29
34
  canvasContainerRef: RefObject<HTMLElement | null>;
@@ -37,11 +42,18 @@ export interface UseDragDropArgs<TS, TC> {
37
42
  emit: (event: string) => void;
38
43
  }
39
44
 
45
+ /** The live drag, and the handler a palette item calls on pointerdown. */
40
46
  export interface UseDragDropResult {
41
47
  drag: DragState | null;
42
48
  startDrag: (item: PaletteItem, e: ReactPointerEvent) => void;
43
49
  }
44
50
 
51
+ /**
52
+ * Run palette-to-canvas drags for a `DragDropCapability`. Releasing over the
53
+ * container converts the pointer to world coordinates, applies `onDrop`'s
54
+ * state and emits `'canvas.itemAdded'`; releasing anywhere else, or a canceled
55
+ * gesture, drops nothing. `onDragOver` is probed at most once per frame.
56
+ */
45
57
  export function useDragDrop<TS, TC>({
46
58
  capability,
47
59
  canvasContainerRef,
@@ -53,8 +65,8 @@ export function useDragDrop<TS, TC>({
53
65
  worldSpec,
54
66
  }: UseDragDropArgs<TS, TC>): UseDragDropResult {
55
67
  const [drag, setDrag] = useState<DragState | null>(null);
68
+ // Owned by the handlers below, which write it alongside every `setDrag`.
56
69
  const dragRef = useRef<DragState | null>(null);
57
- dragRef.current = drag;
58
70
  /** The move the next frame will resolve. Doubles as the throttle's flag:
59
71
  * non-null means a frame is already queued. */
60
72
  const pendingPos = useRef<Point | null>(null);
@@ -62,20 +74,14 @@ export function useDragDrop<TS, TC>({
62
74
 
63
75
  /** A session keeps the closure it was opened with, so everything the drop
64
76
  * 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 };
77
+ const liveRef = useLatest({ capability, state, config, setState, emit, view, worldSpec });
67
78
 
68
79
  const isOverCanvas = useCallback(
69
80
  (screenPos: Point): boolean => {
70
81
  const el = canvasContainerRef.current;
71
82
  if (!el) return false;
72
83
  const r = el.getBoundingClientRect();
73
- return (
74
- screenPos.x >= r.left &&
75
- screenPos.x <= r.right &&
76
- screenPos.y >= r.top &&
77
- screenPos.y <= r.bottom
78
- );
84
+ return boxContainsPoint([r.left, r.top, r.right, r.bottom], screenPos.x, screenPos.y);
79
85
  },
80
86
  [canvasContainerRef],
81
87
  );
@@ -92,7 +98,7 @@ export function useDragDrop<TS, TC>({
92
98
  frame,
93
99
  );
94
100
  },
95
- [canvasContainerRef],
101
+ [canvasContainerRef, liveRef],
96
102
  );
97
103
 
98
104
  const frameLoop = useVisibleRaf(() => {
@@ -106,7 +112,8 @@ export function useDragDrop<TS, TC>({
106
112
  const world = screenToWorldFromContainer(screenPos);
107
113
  if (world) feedback = live.capability.onDragOver(world, active.item, live.state, live.config);
108
114
  }
109
- setDrag({ ...active, screenPos, feedback });
115
+ dragRef.current = { ...active, screenPos, feedback };
116
+ setDrag(dragRef.current);
110
117
  });
111
118
 
112
119
  const clearDrag = useCallback(() => {
@@ -154,7 +161,7 @@ export function useDragDrop<TS, TC>({
154
161
  onCancel: clearDrag,
155
162
  });
156
163
  },
157
- [clearDrag, frameLoop, isOverCanvas, screenToWorldFromContainer],
164
+ [clearDrag, frameLoop, isOverCanvas, liveRef, screenToWorldFromContainer],
158
165
  );
159
166
 
160
167
  useEffect(() => () => sessionRef.current?.cancel(), []);
@@ -162,10 +169,12 @@ export function useDragDrop<TS, TC>({
162
169
  return { drag, startDrag };
163
170
  }
164
171
 
172
+ /** Props for `DragOverlay`. */
165
173
  export interface DragOverlayProps {
166
174
  drag: DragState | null;
167
175
  }
168
176
 
177
+ /** The ghost for `drag`, or nothing when no drag is live. */
169
178
  export function DragOverlay({ drag }: DragOverlayProps) {
170
179
  if (!drag) return null;
171
180
  return <DragGhost item={drag.item} screenPos={drag.screenPos} />;
@@ -2,11 +2,14 @@ import { type ReactPortal, useEffect, useState } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import type { PaletteItem, Point } from '../instrument/types';
4
4
 
5
+ /** Props for `DragGhost`. `screenPos` is in client pixels. */
5
6
  export interface DragGhostProps {
6
7
  item: PaletteItem;
7
8
  screenPos: Point;
8
9
  }
9
10
 
11
+ /** The dragged item's label, portaled to `document.body` just below-right of
12
+ * `screenPos`. Renders nothing until mounted, so it is SSR-safe. */
10
13
  export function DragGhost({ item, screenPos }: DragGhostProps): ReactPortal | null {
11
14
  const [mounted, setMounted] = useState(false);
12
15
  useEffect(() => setMounted(true), []);
@@ -1,12 +1,14 @@
1
1
  import type { PointerEvent as ReactPointerEvent } from 'react';
2
2
  import type { PaletteItem } from '../instrument/types';
3
3
 
4
+ /** Props for `Palette`. `onDragStart` is usually `useDragDrop`'s `startDrag`. */
4
5
  export interface PaletteProps {
5
6
  items: PaletteItem[];
6
7
  onDragStart: (item: PaletteItem, e: ReactPointerEvent) => void;
7
8
  className?: string;
8
9
  }
9
10
 
11
+ /** One button per item, each starting a drag on pointerdown. */
10
12
  export function Palette({ items, onDragStart, className }: PaletteProps) {
11
13
  return (
12
14
  <div className={className ? `lk-palette ${className}` : 'lk-palette'}>
package/src/index.test.ts CHANGED
@@ -49,6 +49,11 @@ describe('public export surface', () => {
49
49
  expect(labkit).toHaveProperty('useTrialState');
50
50
  expect(labkit).toHaveProperty('useTrialId');
51
51
  });
52
+
53
+ it('leaves the loupe to its own entry, so a lab without one does not load it', () => {
54
+ expect(labkit).not.toHaveProperty('TrialLoupe');
55
+ expect(labkit).not.toHaveProperty('useLoupe');
56
+ });
52
57
  });
53
58
 
54
59
  describe('ui re-exports', () => {
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- // The property-panel family, LayerList, Input, Select, TokenPanel, ToggleBar and Button live in
1
+ // The property-panel family, DetailList, LayerList, Input, Select, TokenPanel, ToggleBar and Button live in
2
2
  // `@weasel-js/ui`; labkit re-exports them so chrome built on labkit needs no
3
3
  // direct ui dependency. Named, not `export *` — a star re-export of an external
4
4
  // package emits no binding in the bundle.
@@ -10,6 +10,11 @@ export type {
10
10
  ButtonVariant,
11
11
  CurveFieldProps,
12
12
  CurveMark,
13
+ DetailListLayout,
14
+ DetailListProps,
15
+ DetailListValues,
16
+ DetailRowProps,
17
+ DetailRowStatus,
13
18
  IconProps,
14
19
  InputProps,
15
20
  JogProps,
@@ -41,6 +46,8 @@ export {
41
46
  CloseIcon,
42
47
  CollapseIcon,
43
48
  CurveField,
49
+ DetailList,
50
+ DetailRow,
44
51
  ExpandIcon,
45
52
  Icon,
46
53
  Input,
@@ -73,6 +80,7 @@ export type {
73
80
  AnnotationMeaning,
74
81
  AnnotationPatch,
75
82
  AnnotationQuery,
83
+ AnnotationSelectionMode,
76
84
  AnnotationStatus,
77
85
  AnnotationStoreOptions,
78
86
  AnnotationsApi,
@@ -131,6 +139,8 @@ export type { ContentSize, PaneSize } from './annotations/view';
131
139
  export { fitView, fromWeaselView, toWeaselView } from './annotations/view';
132
140
  export type {
133
141
  CameraContextValue,
142
+ CameraRegistry,
143
+ CameraView,
134
144
  CanvasLayerDescriptor,
135
145
  CanvasStackContextValue,
136
146
  CanvasStackProps,
@@ -140,20 +150,25 @@ export type {
140
150
  WorldFrame,
141
151
  WorldSpec,
142
152
  ZoomAtOptions,
153
+ ZoomRange,
143
154
  } from './canvas';
144
155
  export {
145
156
  applyCamera,
146
157
  CameraContext,
147
158
  CameraInput,
159
+ CameraPublishContext,
160
+ CameraRegistryContext,
148
161
  CameraScope,
149
162
  CanvasStack,
150
163
  CanvasStackContext,
151
164
  centerOn,
165
+ createCameraRegistry,
152
166
  DEFAULT_FRAME,
153
167
  fitStage,
154
168
  resolveFrame,
155
169
  Stage,
156
170
  screenToWorld,
171
+ usePublishCamera,
157
172
  worldToScreen,
158
173
  zoomAt,
159
174
  } from './canvas';
@@ -253,7 +268,6 @@ export * from './job';
253
268
  export * from './lab';
254
269
  export type { LayerListItem, LayerListProps, LayerMove } from './layers';
255
270
  export { LayerList, moveLayers } from './layers';
256
- export * from './loupe';
257
271
  export * from './primitives';
258
272
  export { SPECIMEN_SECTIONS, Specimen } from './specimen/Specimen';
259
273
  export {
@@ -6,7 +6,6 @@ import type { TrialContribution } from '../chrome/types';
6
6
  import type { ConfigPath, ConfigSchema } from '../config/types';
7
7
  import type { ConfigField } from '../controls/types';
8
8
  import type { JobCapability, JobHandle } from '../job/types';
9
- import type { LoupeDeclaration } from '../loupe/types';
10
9
  import type { TrialInfo } from '../state/types';
11
10
  import type { ToolCapability } from '../tools/types';
12
11
 
@@ -182,11 +181,6 @@ export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
182
181
  * is allowed to mean. Declaring it is what makes the trial provide the
183
182
  * annotation overlay and its chrome. */
184
183
  annotations?: AnnotationsCapability<TS, TC>;
185
- /** Magnification. `true` takes every default and re-draws the instrument's
186
- * canvas layers through a zoomed camera; an instrument whose content is DOM
187
- * gives a `render` that draws it again at a camera it is handed. A function
188
- * is re-read as the config changes, so a setting can drive the lens. */
189
- loupe?: LoupeDeclaration<TS, TC> | ((config: TC) => LoupeDeclaration<TS, TC>);
190
184
  /** Chrome this instrument contributes beyond what its capabilities imply. */
191
185
  chrome?: TrialContribution[];
192
186
  /** Work too slow to do during a render. The runtime starts it, aborts it on
package/src/job/useJob.ts CHANGED
@@ -1,6 +1,9 @@
1
+ import { useLatest } from '@weasel-js/core';
1
2
  import { useCallback, useEffect, useRef, useState } from 'react';
2
3
  import type { JobCapability, JobFailure, JobHandle, JobStatus } from './types';
3
4
 
5
+ /** Inputs to `useJob`. `setState` receives each item folded in by the
6
+ * capability's `onItem`. */
4
7
  export interface UseJobOptions<TS, TC, TItem> {
5
8
  capability: JobCapability<TS, TC, TItem>;
6
9
  config: TC;
@@ -23,6 +26,12 @@ function sameKey(a: readonly unknown[] | null, b: readonly unknown[] | null): bo
23
26
  return a.length === b.length && a.every((v, i) => Object.is(v, b[i]));
24
27
  }
25
28
 
29
+ /**
30
+ * Drive a `JobCapability` and report its progress. `start` cancels any run in
31
+ * flight and begins again; results from a superseded or canceled run are
32
+ * dropped. With `auto`, it starts on mount and again whenever `key` changes
33
+ * element-wise. Unmounting aborts the run.
34
+ */
26
35
  export function useJob<TS, TC, TItem>({
27
36
  capability,
28
37
  config,
@@ -38,14 +47,10 @@ export function useJob<TS, TC, TItem>({
38
47
 
39
48
  // Read through refs: `run` is called once per run and must see the values as of
40
49
  // that moment rather than re-subscribing on every render.
41
- const capRef = useRef(capability);
42
- capRef.current = capability;
43
- const configRef = useRef(config);
44
- configRef.current = config;
45
- const stateRef = useRef(state);
46
- stateRef.current = state;
47
- const setStateRef = useRef(setState);
48
- setStateRef.current = setState;
50
+ const capRef = useLatest(capability);
51
+ const configRef = useLatest(config);
52
+ const stateRef = useLatest(state);
53
+ const setStateRef = useLatest(setState);
49
54
 
50
55
  const cancel = useCallback(() => {
51
56
  token.current += 1;
@@ -96,7 +101,7 @@ export function useJob<TS, TC, TItem>({
96
101
  }));
97
102
  }
98
103
  })();
99
- }, []);
104
+ }, [capRef, configRef, setStateRef, stateRef]);
100
105
 
101
106
  // Re-run when the declared key changes. `auto` covers the first mount too.
102
107
  const lastKey = useRef<readonly unknown[] | null>(null);
package/src/lab/Lab.tsx CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  type ReactNode,
6
6
  useCallback,
7
7
  useEffect,
8
+ useInsertionEffect,
8
9
  useLayoutEffect,
9
10
  useMemo,
10
11
  useRef,
@@ -13,6 +14,7 @@ import {
13
14
  import { useStore } from 'zustand/react';
14
15
  import { AnnotationPreloadContext } from '../annotations/preload';
15
16
  import { labAnnotationTools } from '../annotations/toolMap';
17
+ import { CameraRegistryContext, createCameraRegistry } from '../canvas/cameraRegistry';
16
18
  import {
17
19
  LabAsideRegion,
18
20
  LabFooterRegion,
@@ -50,10 +52,11 @@ import {
50
52
  } from '../trial/trialOps';
51
53
  import { useLabFitWarning } from './fitCheck';
52
54
  import { LabContext, type LabContextValue } from './LabContext';
53
- import { LabHeader } from './LabHeader';
55
+ import { LabHeader, LabThemeSwitcher } from './LabHeader';
54
56
  import { LabPalette } from './LabPalette';
55
57
  import { LabShell } from './LabShell';
56
58
  import type { LabPage } from './LabSwitcher';
59
+ import { LabZoom } from './LabZoom';
57
60
  import { createPanelHostRegistry, PanelHostContext } from './panelHost';
58
61
  import { type PanelDescriptor, type TrialLayout, Workspace } from './Workspace';
59
62
 
@@ -104,6 +107,9 @@ interface LabBaseProps {
104
107
  /** Offer the header's add-trial control. Default `true`; a lab that opens
105
108
  * trials some other way passes `false`. */
106
109
  addTrial?: boolean;
110
+ /** Offer the header's zoom controls, which act on the focused trial's camera
111
+ * and answer Mod+=, Mod+- and Mod+0. Default `true`. */
112
+ zoom?: boolean;
107
113
  /** Tools offered lab-wide. A trial whose instrument declares none of its own
108
114
  * reflects and writes this slot. */
109
115
  tools?: readonly TrialTool[];
@@ -339,6 +345,7 @@ function LabRuntime({
339
345
  labChrome,
340
346
  suppress,
341
347
  addTrial,
348
+ zoom = true,
342
349
  tools,
343
350
  configRules,
344
351
  controls,
@@ -354,6 +361,7 @@ function LabRuntime({
354
361
  }, [instruments, store]);
355
362
 
356
363
  const trials = useStore(store, (s) => s.trials);
364
+ const [cameras] = useState(createCameraRegistry);
357
365
  const [focusPick, setFocusPick] = useState<string | null>(null);
358
366
  const focusedTrialId = trials.some((t) => t.id === focusPick)
359
367
  ? focusPick
@@ -411,7 +419,10 @@ function LabRuntime({
411
419
  }, []);
412
420
 
413
421
  const ownSurface = useTiledSurface({ onFrame });
414
- surfaceRef.current = ownSurface;
422
+ // Not `useLatest`: `onFrame` reads it, and is needed before the surface exists.
423
+ useInsertionEffect(() => {
424
+ surfaceRef.current = ownSurface;
425
+ }, [ownSurface]);
415
426
  const [labBody, setLabBody] = useState<HTMLDivElement | null>(null);
416
427
  const attachOwnSurface = ownSurface.containerRef;
417
428
  const labBodyRef = useCallback(
@@ -612,79 +623,83 @@ function LabRuntime({
612
623
  <PersistenceContext.Provider value={opened.records}>
613
624
  <AnnotationPreloadContext.Provider value={opened.marks}>
614
625
  <LabContext.Provider value={contextValue}>
615
- <ThemeProvider
616
- theme={theme}
617
- selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
618
- className="lk-lab"
619
- style={backdropStyle}
620
- >
621
- <LabShell
622
- title={title ?? 'Labkit'}
623
- mode={modeValue}
624
- {...(pages ? { pages } : {})}
625
- {...(path !== undefined ? { path } : {})}
626
- footer={
627
- hasFooterChrome ? (
626
+ <CameraRegistryContext.Provider value={cameras}>
627
+ <ThemeProvider
628
+ theme={theme}
629
+ selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
630
+ className="lk-lab"
631
+ style={backdropStyle}
632
+ >
633
+ <LabShell
634
+ title={title ?? 'Labkit'}
635
+ mode={modeValue}
636
+ {...(pages ? { pages } : {})}
637
+ {...(path !== undefined ? { path } : {})}
638
+ footer={
639
+ hasFooterChrome ? (
640
+ <>
641
+ {footer}
642
+ <LabFooterRegion contributions={labChromeAll} />
643
+ </>
644
+ ) : (
645
+ footer
646
+ )
647
+ }
648
+ header={
628
649
  <>
629
- {footer}
630
- <LabFooterRegion contributions={labChromeAll} />
650
+ <LabHeader {...(addTrial !== undefined ? { addTrial } : {})} />
651
+ {zoom ? <LabZoom /> : null}
652
+ {children}
653
+ <LabHeaderRegion contributions={labChromeAll} />
654
+ <LabThemeSwitcher />
631
655
  </>
632
- ) : (
633
- footer
634
- )
635
- }
636
- header={
637
- <>
638
- <LabHeader {...(addTrial !== undefined ? { addTrial } : {})} />
639
- {children}
640
- <LabHeaderRegion contributions={labChromeAll} />
641
- </>
642
- }
643
- >
644
- <PanelHostContext.Provider value={panelHostsRef.current}>
645
- <SurfaceContext.Provider value={surface}>
646
- <SurfaceCanvasContext.Provider value={surfaceCanvases}>
647
- <div className="lk-lab__body" ref={labBodyRef}>
648
- {outerSurface ? null : (
649
- // Two buffers stacked around the trials, both inert —
650
- // each tile takes input from its own box. A tile's marks
651
- // annotate the instrument's DOM from over it; an opaque
652
- // renderer sits under it, so the pane can still hold a
653
- // label. The under one is first so it paints first.
654
- <>
655
- <canvas
656
- className="lk-lab__surface lk-lab__surface--under"
657
- ref={(el) => {
658
- ownUnderRef.current = el;
659
- setOwnUnder(el);
660
- }}
661
- />
662
- <canvas
663
- className="lk-lab__surface lk-lab__surface--over"
664
- ref={(el) => {
665
- ownOverRef.current = el;
666
- setOwnOver(el);
667
- }}
668
- />
669
- </>
670
- )}
671
- {hasPaneChrome ? (
672
- <LabPanes contributions={labChromeAll}>
673
- <LabPalette contributions={labChromeAll} />
674
- {workspace}
675
- </LabPanes>
676
- ) : (
677
- <>
678
- <LabPalette contributions={labChromeAll} />
679
- {workspace}
680
- </>
681
- )}
682
- </div>
683
- </SurfaceCanvasContext.Provider>
684
- </SurfaceContext.Provider>
685
- </PanelHostContext.Provider>
686
- </LabShell>
687
- </ThemeProvider>
656
+ }
657
+ >
658
+ <PanelHostContext.Provider value={panelHostsRef.current}>
659
+ <SurfaceContext.Provider value={surface}>
660
+ <SurfaceCanvasContext.Provider value={surfaceCanvases}>
661
+ <div className="lk-lab__body" ref={labBodyRef}>
662
+ {outerSurface ? null : (
663
+ // Two buffers stacked around the trials, both inert —
664
+ // each tile takes input from its own box. A tile's marks
665
+ // annotate the instrument's DOM from over it; an opaque
666
+ // renderer sits under it, so the pane can still hold a
667
+ // label. The under one is first so it paints first.
668
+ <>
669
+ <canvas
670
+ className="lk-lab__surface lk-lab__surface--under"
671
+ ref={(el) => {
672
+ ownUnderRef.current = el;
673
+ setOwnUnder(el);
674
+ }}
675
+ />
676
+ <canvas
677
+ className="lk-lab__surface lk-lab__surface--over"
678
+ ref={(el) => {
679
+ ownOverRef.current = el;
680
+ setOwnOver(el);
681
+ }}
682
+ />
683
+ </>
684
+ )}
685
+ {hasPaneChrome ? (
686
+ <LabPanes contributions={labChromeAll}>
687
+ <LabPalette contributions={labChromeAll} />
688
+ {workspace}
689
+ </LabPanes>
690
+ ) : (
691
+ <>
692
+ <LabPalette contributions={labChromeAll} />
693
+ {workspace}
694
+ </>
695
+ )}
696
+ </div>
697
+ </SurfaceCanvasContext.Provider>
698
+ </SurfaceContext.Provider>
699
+ </PanelHostContext.Provider>
700
+ </LabShell>
701
+ </ThemeProvider>
702
+ </CameraRegistryContext.Provider>
688
703
  </LabContext.Provider>
689
704
  </AnnotationPreloadContext.Provider>
690
705
  </PersistenceContext.Provider>
@@ -1,4 +1,4 @@
1
- // Host pages for LabFit.stories. Each is scoped by its own class, so a story
1
+ // Host pages for LabFit.browser.test. Each is scoped by its own class, so a test
2
2
  // that mounts no fixture leaves the page untouched.
3
3
 
4
4
  // The mount every example documents: `html, body, #root { margin: 0; height: 100% }`.
@@ -1,15 +1,21 @@
1
- import type { Meta, StoryObj } from '@weasel-js/forge';
2
- import { expect, userEvent, waitFor, within } from '@weasel-js/forge/play';
1
+ import '@weasel-js/theme/tokens.css';
2
+ import 'windease/styles.css';
3
+ import '../styles.less';
4
+ import './LabFit.browser.test.less';
5
+ import { cleanup, render, screen, waitFor } from '@testing-library/react';
3
6
  import { type ReactNode, useLayoutEffect, useState } from 'react';
4
7
  import { createPortal } from 'react-dom';
8
+ import { afterEach, expect, test } from 'vitest';
9
+ import { page, userEvent } from 'vitest/browser';
5
10
  import type { LabContribution } from '../chrome/labTypes';
6
11
  import type { TrialContribution } from '../chrome/types';
7
12
  import type { Instrument } from '../instrument/types';
8
13
  import { Lab } from './Lab';
9
- import './LabFit.stories.less';
10
14
 
11
- // A lab must fit whatever it is mounted in without scrolling. jsdom cannot see
12
- // layout, so these run in the forge-stories vitest project's real browser.
15
+ // A lab must fit whatever it is mounted in without scrolling, which only real
16
+ // layout can show.
17
+
18
+ afterEach(cleanup);
13
19
 
14
20
  const Stub: Instrument = {
15
21
  name: 'Stub',
@@ -158,62 +164,34 @@ function fitProblems(fixture: Fixture, viewport: keyof typeof VIEWPORTS): string
158
164
  return problems;
159
165
  }
160
166
 
161
- function fit(fixture: Fixture, viewport: keyof typeof VIEWPORTS): Story {
162
- return {
163
- globals: { viewport: { value: viewport, isRotated: false } },
164
- render: () => (
165
- <BodyHost host={HOST[fixture]}>
166
- <Lab
167
- title={`Fit: ${fixture}`}
168
- instruments={[Stub]}
169
- defaultInstrument="Stub"
170
- chrome={fixture === 'floating' ? [floater] : undefined}
171
- labChrome={fixture === 'sidebar' ? [sidebarTree] : undefined}
172
- />
173
- </BodyHost>
174
- ),
175
- play: async () => {
176
- if (fixture === 'floating') {
177
- const undock = await within(document.body).findByRole('button', { name: 'Undock Floater' });
178
- await userEvent.click(undock);
179
- }
180
- await waitFor(
181
- () => {
182
- const problems = fitProblems(fixture, viewport);
183
- if (problems.length > 0) throw new Error(problems.join('; '));
184
- },
185
- { timeout: 2000 },
186
- );
187
- await expect(fitProblems(fixture, viewport)).toEqual([]);
167
+ const FIXTURES: Fixture[] = ['fullscreen', 'floating', 'wrapped', 'framed', 'sidebar'];
168
+ const CASES = FIXTURES.flatMap((fixture) =>
169
+ (Object.keys(VIEWPORTS) as (keyof typeof VIEWPORTS)[]).map((viewport) => ({ fixture, viewport })),
170
+ );
171
+
172
+ test.each(CASES)('$fixture fits a $viewport viewport', async ({ fixture, viewport }) => {
173
+ const { width, height } = VIEWPORTS[viewport].styles;
174
+ await page.viewport(Number.parseInt(width, 10), Number.parseInt(height, 10));
175
+ render(
176
+ <BodyHost host={HOST[fixture]}>
177
+ <Lab
178
+ title={`Fit: ${fixture}`}
179
+ instruments={[Stub]}
180
+ defaultInstrument="Stub"
181
+ chrome={fixture === 'floating' ? [floater] : undefined}
182
+ labChrome={fixture === 'sidebar' ? [sidebarTree] : undefined}
183
+ />
184
+ </BodyHost>,
185
+ );
186
+ if (fixture === 'floating') {
187
+ await userEvent.click(await screen.findByRole('button', { name: 'Undock Floater' }));
188
+ }
189
+ await waitFor(
190
+ () => {
191
+ const problems = fitProblems(fixture, viewport);
192
+ if (problems.length > 0) throw new Error(problems.join('; '));
188
193
  },
189
- };
190
- }
191
-
192
- const meta: Meta<typeof Lab> = {
193
- title: 'labkit/Lab/Fit',
194
- component: Lab,
195
- parameters: { layout: 'fullscreen', viewport: { options: VIEWPORTS } },
196
- };
197
- export default meta;
198
-
199
- type Story = StoryObj<typeof Lab>;
200
-
201
- export const FullscreenWide = fit('fullscreen', 'wide');
202
- export const FullscreenMedium = fit('fullscreen', 'medium');
203
- export const FullscreenNarrow = fit('fullscreen', 'narrow');
204
-
205
- export const FloatingPanelWide = fit('floating', 'wide');
206
- export const FloatingPanelMedium = fit('floating', 'medium');
207
- export const FloatingPanelNarrow = fit('floating', 'narrow');
208
-
209
- export const WrappedNoResetWide = fit('wrapped', 'wide');
210
- export const WrappedNoResetMedium = fit('wrapped', 'medium');
211
- export const WrappedNoResetNarrow = fit('wrapped', 'narrow');
212
-
213
- export const FramedWide = fit('framed', 'wide');
214
- export const FramedMedium = fit('framed', 'medium');
215
- export const FramedNarrow = fit('framed', 'narrow');
216
-
217
- export const SidebarWide = fit('sidebar', 'wide');
218
- export const SidebarMedium = fit('sidebar', 'medium');
219
- export const SidebarNarrow = fit('sidebar', 'narrow');
194
+ { timeout: 2000 },
195
+ );
196
+ expect(fitProblems(fixture, viewport)).toEqual([]);
197
+ });
@@ -252,6 +252,7 @@ const runLog = defineInstrument<{ lines: string[] }, Record<string, never>>({
252
252
  const meta: Meta<typeof Lab> = {
253
253
  title: 'labkit/Lab/FullChrome',
254
254
  component: Lab,
255
+ tags: ['gallery'],
255
256
  parameters: { layout: 'fullscreen' },
256
257
  };
257
258
  export default meta;