@weasel-js/labkit 1.7.0 → 1.7.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 (242) hide show
  1. package/README.md +3 -3
  2. package/dist/_dts/{CanvasStackContext-D2jnIwfk.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
  3. package/dist/_dts/{Stage-CCWfQQWM.d.ts → Stage-D6t7o0pF.d.ts} +3 -2
  4. package/dist/_dts/{index-CsYqUHD3.d.ts → index-BTmaRy6a.d.ts} +10 -6
  5. package/dist/_dts/{types-CSm9jgol.d.ts → index-DCMA4Fy1.d.ts} +59 -113
  6. package/dist/_dts/{types-CRPSGFAp.d.ts → types-BUBSIZuV.d.ts} +8 -7
  7. package/dist/_dts/{useTrialState-CTZPgeZ7.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 +7 -5
  11. package/dist/chrome/index.js +10 -9
  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-275JRRVD.js → chunk-E23Q6A3Y.js} +232 -37
  17. package/dist/chunk-E23Q6A3Y.js.map +1 -0
  18. package/dist/{chunk-GOZLNM4L.js → chunk-E6NARGU7.js} +5 -5
  19. package/dist/{chunk-GOZLNM4L.js.map → chunk-E6NARGU7.js.map} +1 -1
  20. package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
  21. package/dist/chunk-EESJUSYR.js.map +1 -0
  22. package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
  23. package/dist/chunk-EFXRM6XQ.js.map +1 -0
  24. package/dist/chunk-F4ACIK7Z.js +210 -0
  25. package/dist/chunk-F4ACIK7Z.js.map +1 -0
  26. package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
  27. package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
  28. package/dist/{chunk-YT75VYRD.js → chunk-IBVRMHW5.js} +5 -5
  29. package/dist/{chunk-YT75VYRD.js.map → chunk-IBVRMHW5.js.map} +1 -1
  30. package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
  31. package/dist/chunk-PMLZP7RT.js.map +1 -0
  32. package/dist/{chunk-Y23G2VZL.js → chunk-READTDQX.js} +2 -2
  33. package/dist/chunk-READTDQX.js.map +1 -0
  34. package/dist/{chunk-I5DUQ5HM.js → chunk-RODIY7HI.js} +75 -72
  35. package/dist/chunk-RODIY7HI.js.map +1 -0
  36. package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
  37. package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
  38. package/dist/{chunk-ZIFPHIWZ.js → chunk-T6DLGEJB.js} +12 -14
  39. package/dist/chunk-T6DLGEJB.js.map +1 -0
  40. package/dist/chunk-U6UQVGFP.js +15583 -0
  41. package/dist/chunk-U6UQVGFP.js.map +1 -0
  42. package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
  43. package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
  44. package/dist/{chunk-UNAZSLWE.js → chunk-VYVGVBGY.js} +17 -20
  45. package/dist/chunk-VYVGVBGY.js.map +1 -0
  46. package/dist/chunk-WFALF2X2.js +302 -0
  47. package/dist/chunk-WFALF2X2.js.map +1 -0
  48. package/dist/{chunk-KMJ7RWD3.js → chunk-WLMDTI2Q.js} +3 -3
  49. package/dist/{chunk-KMJ7RWD3.js.map → chunk-WLMDTI2Q.js.map} +1 -1
  50. package/dist/{chunk-T5IBSPAM.js → chunk-WWJDPNZZ.js} +43 -33
  51. package/dist/chunk-WWJDPNZZ.js.map +1 -0
  52. package/dist/{chunk-J7OWWTCW.js → chunk-XE5HJOPL.js} +5 -6
  53. package/dist/chunk-XE5HJOPL.js.map +1 -0
  54. package/dist/{chunk-7BO7HNGF.js → chunk-XRX5S5NT.js} +8 -61
  55. package/dist/chunk-XRX5S5NT.js.map +1 -0
  56. package/dist/chunk-XVUKDSYV.js +38 -0
  57. package/dist/chunk-XVUKDSYV.js.map +1 -0
  58. package/dist/{chunk-FMK7UQ6V.js → chunk-XZDCEMEL.js} +64 -47
  59. package/dist/chunk-XZDCEMEL.js.map +1 -0
  60. package/dist/{chunk-FZ7YFQN7.js → chunk-YCFKW5RX.js} +3 -3
  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 +18 -10
  65. package/dist/config/index.js +4 -4
  66. package/dist/controls/index.d.ts +58 -9
  67. package/dist/controls/index.js +7 -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 +61 -715
  72. package/dist/index.js +456 -432
  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 +15 -14
  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 -2871
  86. package/dist/passthrough/weasel-ui.js +3 -2
  87. package/dist/primitives/index.d.ts +213 -3
  88. package/dist/primitives/index.js +8 -7
  89. package/dist/state/index.d.ts +7 -5
  90. package/dist/state/index.js +6 -6
  91. package/dist/styles.css +284 -51
  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 +11 -11
  96. package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
  97. package/src/annotations/AnnotationOverlay.tsx +50 -31
  98. package/src/annotations/Annotations.overlay.test.tsx +31 -0
  99. package/src/annotations/capture.browser.test.ts +47 -0
  100. package/src/annotations/capture.ts +14 -7
  101. package/src/annotations/drawOne.ts +2 -2
  102. package/src/annotations/frac.test.ts +44 -1
  103. package/src/annotations/frac.ts +46 -18
  104. package/src/annotations/index.ts +1 -0
  105. package/src/annotations/paint.test.ts +74 -33
  106. package/src/annotations/paint.ts +70 -32
  107. package/src/annotations/store.test.ts +212 -2
  108. package/src/annotations/store.ts +130 -27
  109. package/src/annotations/svgNodes.test.ts +22 -10
  110. package/src/annotations/svgNodes.ts +3 -3
  111. package/src/annotations/types.ts +29 -10
  112. package/src/canvas/CameraInput.test.tsx +30 -0
  113. package/src/canvas/CameraInput.tsx +16 -15
  114. package/src/canvas/CanvasStack.tsx +5 -5
  115. package/src/canvas/CanvasStackContext.ts +4 -1
  116. package/src/canvas/Stage.test.tsx +34 -25
  117. package/src/canvas/Stage.tsx +3 -3
  118. package/src/canvas/cameraRegistry.ts +1 -0
  119. package/src/canvas/worldSpec.ts +3 -1
  120. package/src/chrome/builtins.test.ts +7 -6
  121. package/src/chrome/builtins.tsx +5 -1
  122. package/src/chrome/index.ts +1 -1
  123. package/src/chrome/labRegions.test.tsx +37 -26
  124. package/src/chrome/merge.test.ts +5 -0
  125. package/src/chrome/merge.ts +4 -2
  126. package/src/chrome/regions/PaletteRegion.test.tsx +4 -5
  127. package/src/chrome/regions/PaletteRegion.tsx +3 -4
  128. package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
  129. package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
  130. package/src/chrome/regions/ViewportRegion.less +6 -0
  131. package/src/chrome/regions/ViewportRegion.tsx +10 -4
  132. package/src/chrome/types.ts +4 -2
  133. package/src/config/auto.ts +1 -0
  134. package/src/config/builder.ts +6 -0
  135. package/src/config/types.ts +2 -1
  136. package/src/controls/ControlMatrix.less +245 -0
  137. package/src/controls/ControlMatrix.stories.less +13 -0
  138. package/src/controls/ControlMatrix.stories.tsx +135 -0
  139. package/src/controls/ControlMatrix.test.tsx +206 -0
  140. package/src/controls/ControlMatrix.tsx +446 -0
  141. package/src/controls/ControlPanel.test.tsx +37 -3
  142. package/src/controls/ControlPanel.tsx +16 -5
  143. package/src/controls/index.ts +6 -0
  144. package/src/dragdrop/DragDropRuntime.tsx +22 -13
  145. package/src/dragdrop/DragGhost.tsx +3 -0
  146. package/src/dragdrop/Palette.tsx +2 -0
  147. package/src/dragdrop/dragDrop.test.tsx +34 -33
  148. package/src/index.test.ts +5 -0
  149. package/src/index.ts +7 -1
  150. package/src/instrument/types.ts +0 -6
  151. package/src/job/useJob.ts +14 -9
  152. package/src/lab/Lab.chrome.test.tsx +15 -10
  153. package/src/lab/Lab.nebula.test.tsx +5 -5
  154. package/src/lab/Lab.surface.test.tsx +30 -21
  155. package/src/lab/Lab.test.tsx +75 -74
  156. package/src/lab/Lab.tsx +5 -1
  157. package/src/lab/Lab.undock.test.tsx +15 -10
  158. package/src/lab/LabHeader.test.tsx +15 -14
  159. package/src/lab/LabPalette.test.tsx +12 -7
  160. package/src/lab/LabShell.less +1 -1
  161. package/src/lab/LabSwitcher.browser.test.tsx +27 -0
  162. package/src/lab/LabSwitcher.less +2 -0
  163. package/src/lab/LabZoom.test.tsx +55 -44
  164. package/src/lab/LabZoom.tsx +4 -4
  165. package/src/lab/Workspace.surface.test.tsx +16 -13
  166. package/src/lab/Workspace.test.tsx +32 -27
  167. package/src/lab/Workspace.tsx +48 -52
  168. package/src/loupe/AGENTS.md +28 -15
  169. package/src/loupe/CanvasLoupe.tsx +2 -3
  170. package/src/loupe/DomLoupe.tsx +1 -7
  171. package/src/loupe/Loupe.less +16 -0
  172. package/src/loupe/TrialLoupe.tsx +120 -57
  173. package/src/loupe/index.ts +1 -6
  174. package/src/loupe/types.test.ts +3 -3
  175. package/src/loupe/types.ts +24 -27
  176. package/src/loupe/useLoupe.test.tsx +29 -4
  177. package/src/loupe/useLoupe.ts +20 -17
  178. package/src/overview/OverviewMarks.test.tsx +9 -8
  179. package/src/overview/OverviewMarks.tsx +12 -4
  180. package/src/overview/TrialOverview.test.tsx +18 -17
  181. package/src/overview/TrialOverview.tsx +3 -3
  182. package/src/passthrough/weasel-ui.test.ts +1 -1
  183. package/src/passthrough/weasel-ui.ts +1 -1
  184. package/src/primitives/FloatingPanel.tsx +7 -3
  185. package/src/primitives/FpsMeter.less +1 -2
  186. package/src/primitives/JobProgress.less +1 -1
  187. package/src/primitives/Readout.test.tsx +2 -2
  188. package/src/primitives/ScaleIndicator.less +1 -1
  189. package/src/primitives/Split.tsx +69 -41
  190. package/src/primitives/StatusBar.less +1 -1
  191. package/src/primitives/Toolbar.tsx +3 -3
  192. package/src/primitives/ZoomControl.less +1 -2
  193. package/src/specimen/Specimen.less +1 -0
  194. package/src/specimen/Specimen.tsx +0 -1
  195. package/src/state/undock.ts +3 -0
  196. package/src/state/useOpenOnce.ts +20 -16
  197. package/src/state/view.ts +1 -0
  198. package/src/styles.less +3 -0
  199. package/src/surface/index.ts +1 -1
  200. package/src/surface/useTiledSurface.test.tsx +14 -0
  201. package/src/surface/useTiledSurface.ts +11 -3
  202. package/src/trial/Trial.annotations.persist.test.tsx +20 -9
  203. package/src/trial/Trial.annotations.test.tsx +105 -27
  204. package/src/trial/Trial.canvas.test.tsx +21 -17
  205. package/src/trial/Trial.config.test.tsx +34 -27
  206. package/src/trial/Trial.less +0 -16
  207. package/src/trial/Trial.loupe.test.tsx +51 -25
  208. package/src/trial/Trial.targets.test.tsx +9 -8
  209. package/src/trial/Trial.test.tsx +51 -46
  210. package/src/trial/Trial.trialId.test.tsx +7 -6
  211. package/src/trial/Trial.tsx +74 -62
  212. package/src/trial/TrialChrome.tsx +6 -3
  213. package/src/trial/index.ts +2 -0
  214. package/src/trial/loupeSwitch.ts +15 -0
  215. package/dist/_dts/LayerList.d-DiuEArKq.d.ts +0 -83
  216. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +0 -79
  217. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +0 -141
  218. package/dist/_dts/Select.d-Bensr_MT.d.ts +0 -579
  219. package/dist/_dts/index-B2Yj5aA0.d.ts +0 -274
  220. package/dist/chunk-275JRRVD.js.map +0 -1
  221. package/dist/chunk-4LJSC7MB.js +0 -143
  222. package/dist/chunk-4LJSC7MB.js.map +0 -1
  223. package/dist/chunk-7BO7HNGF.js.map +0 -1
  224. package/dist/chunk-CQLQPQ4P.js.map +0 -1
  225. package/dist/chunk-FMK7UQ6V.js.map +0 -1
  226. package/dist/chunk-FZ7YFQN7.js.map +0 -1
  227. package/dist/chunk-HXXTULDN.js.map +0 -1
  228. package/dist/chunk-I5DUQ5HM.js.map +0 -1
  229. package/dist/chunk-IJJUK4FU.js.map +0 -1
  230. package/dist/chunk-J7OWWTCW.js.map +0 -1
  231. package/dist/chunk-M7HP3FTK.js +0 -549
  232. package/dist/chunk-M7HP3FTK.js.map +0 -1
  233. package/dist/chunk-MIM5R4ZT.js +0 -14972
  234. package/dist/chunk-MIM5R4ZT.js.map +0 -1
  235. package/dist/chunk-RWNDDKOH.js.map +0 -1
  236. package/dist/chunk-T5IBSPAM.js.map +0 -1
  237. package/dist/chunk-UNAZSLWE.js.map +0 -1
  238. package/dist/chunk-WPLAANH4.js.map +0 -1
  239. package/dist/chunk-Y23G2VZL.js.map +0 -1
  240. package/dist/chunk-ZIFPHIWZ.js.map +0 -1
  241. package/src/primitives/useRovingTabIndex.test.ts +0 -48
  242. package/src/primitives/useRovingTabIndex.ts +0 -88
@@ -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'}>
@@ -1,4 +1,5 @@
1
- import { act, fireEvent, render, screen } from '@testing-library/react';
1
+ import { act, fireEvent, screen } from '@testing-library/react';
2
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
2
3
  import { useContext } from 'react';
3
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
5
  import { useStore } from 'zustand/react';
@@ -51,8 +52,8 @@ const centredInstrument = defineInstrument<DropState, Record<string, never>>({
51
52
  },
52
53
  });
53
54
 
54
- function renderLab(probe?: (state: DropState) => void, instrument = testInstrument) {
55
- return render(
55
+ async function renderLab(probe?: (state: DropState) => void, instrument = testInstrument) {
56
+ return renderSettled(
56
57
  <Lab instruments={[instrument]} defaultInstrument={instrument.name}>
57
58
  {probe ? <StateProbe onState={probe} /> : null}
58
59
  </Lab>,
@@ -88,104 +89,104 @@ function pointerDown(el: Element, x: number, y: number) {
88
89
 
89
90
  /** The session listens on the document, so that is where the rest of the
90
91
  * gesture has to arrive — a dispatch on `window` reaches nothing. */
91
- function dispatchOnDocument(type: string, init: PointerEventInit) {
92
- act(() => {
92
+ async function dispatchOnDocument(type: string, init: PointerEventInit) {
93
+ await act(async () => {
93
94
  document.dispatchEvent(new PointerEvent(type, { pointerId: 1, bubbles: true, ...init }));
94
95
  });
95
96
  }
96
97
 
97
- function pointerMove(x: number, y: number) {
98
- dispatchOnDocument('pointermove', { buttons: 1, clientX: x, clientY: y });
98
+ async function pointerMove(x: number, y: number) {
99
+ await dispatchOnDocument('pointermove', { buttons: 1, clientX: x, clientY: y });
99
100
  }
100
101
 
101
- function pointerUp(x: number, y: number) {
102
- dispatchOnDocument('pointerup', { clientX: x, clientY: y });
102
+ async function pointerUp(x: number, y: number) {
103
+ await dispatchOnDocument('pointerup', { clientX: x, clientY: y });
103
104
  }
104
105
 
105
106
  describe('DragDrop integration', () => {
106
- it('renders palette item in the sidebar', () => {
107
- renderLab();
107
+ it('renders palette item in the sidebar', async () => {
108
+ await renderLab();
108
109
  expect(screen.getByRole('button', { name: 'Item A' })).toBeInTheDocument();
109
110
  });
110
111
 
111
- it('drops an item onto the canvas and appends it to state', () => {
112
+ it('drops an item onto the canvas and appends it to state', async () => {
112
113
  let latest: DropState = { items: [] };
113
- renderLab((s) => {
114
+ await renderLab((s) => {
114
115
  latest = s;
115
116
  });
116
117
  const palette = screen.getByRole('button', { name: 'Item A' });
117
118
 
118
- act(() => {
119
+ await act(async () => {
119
120
  pointerDown(palette, 0, 0);
120
121
  });
121
- pointerUp(250, 250);
122
+ await pointerUp(250, 250);
122
123
 
123
124
  expect(latest.items).toHaveLength(1);
124
125
  expect(latest.items[0]?.id).toBe('a');
125
126
  });
126
127
 
127
- it('drop outside the canvas leaves state unchanged', () => {
128
+ it('drop outside the canvas leaves state unchanged', async () => {
128
129
  let latest: DropState = { items: [] };
129
- renderLab((s) => {
130
+ await renderLab((s) => {
130
131
  latest = s;
131
132
  });
132
133
  const palette = screen.getByRole('button', { name: 'Item A' });
133
134
 
134
- act(() => {
135
+ await act(async () => {
135
136
  pointerDown(palette, 0, 0);
136
137
  });
137
- pointerMove(10, 10);
138
- pointerUp(10, 10);
138
+ await pointerMove(10, 10);
139
+ await pointerUp(10, 10);
139
140
 
140
141
  expect(latest.items).toHaveLength(0);
141
142
  expect(document.querySelector('.lk-drag-ghost')).toBeNull();
142
143
  });
143
144
 
144
- it('shows drag ghost during drag and clears on drop', () => {
145
- renderLab();
145
+ it('shows drag ghost during drag and clears on drop', async () => {
146
+ await renderLab();
146
147
  const palette = screen.getByRole('button', { name: 'Item A' });
147
148
 
148
- act(() => {
149
+ await act(async () => {
149
150
  pointerDown(palette, 5, 5);
150
151
  });
151
152
  expect(document.querySelector('.lk-drag-ghost')).not.toBeNull();
152
153
 
153
- pointerUp(250, 250);
154
+ await pointerUp(250, 250);
154
155
  expect(document.querySelector('.lk-drag-ghost')).toBeNull();
155
156
  });
156
157
 
157
- it('a cancelled gesture drops nothing and clears the ghost', () => {
158
+ it('a cancelled gesture drops nothing and clears the ghost', async () => {
158
159
  let latest: DropState = { items: [] };
159
- renderLab((s) => {
160
+ await renderLab((s) => {
160
161
  latest = s;
161
162
  });
162
163
  const palette = screen.getByRole('button', { name: 'Item A' });
163
164
 
164
- act(() => {
165
+ await act(async () => {
165
166
  pointerDown(palette, 0, 0);
166
167
  });
167
- pointerMove(250, 250);
168
+ await pointerMove(250, 250);
168
169
  expect(document.querySelector('.lk-drag-ghost')).not.toBeNull();
169
170
 
170
- dispatchOnDocument('pointercancel', {});
171
+ await dispatchOnDocument('pointercancel', {});
171
172
 
172
173
  expect(latest.items).toHaveLength(0);
173
174
  expect(document.querySelector('.lk-drag-ghost')).toBeNull();
174
175
  });
175
176
 
176
- it('drops through the instrument world spec, not the canvas top-left', () => {
177
+ it('drops through the instrument world spec, not the canvas top-left', async () => {
177
178
  let latest: DropState = { items: [] };
178
- renderLab((s) => {
179
+ await renderLab((s) => {
179
180
  latest = s;
180
181
  }, centredInstrument);
181
182
  const palette = screen.getByRole('button', { name: 'Item A' });
182
183
 
183
- act(() => {
184
+ await act(async () => {
184
185
  pointerDown(palette, 0, 0);
185
186
  });
186
187
  // The host spans (100,100)-(500,500), so its centre is at (300,300).
187
188
  // Dropping 50px right of centre and 50px above it, at zoom 1, y up.
188
- pointerUp(350, 250);
189
+ await pointerUp(350, 250);
189
190
 
190
191
  expect(latest.items[0]).toMatchObject({ x: 50, y: 50 });
191
192
  });
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
@@ -80,6 +80,7 @@ export type {
80
80
  AnnotationMeaning,
81
81
  AnnotationPatch,
82
82
  AnnotationQuery,
83
+ AnnotationSelectionMode,
83
84
  AnnotationStatus,
84
85
  AnnotationStoreOptions,
85
86
  AnnotationsApi,
@@ -228,6 +229,12 @@ export type {
228
229
  export { useConfigSchema } from './config/useConfigSchema';
229
230
  export { useResolvedConfig } from './config/useResolvedConfig';
230
231
  export { isLeafVisible } from './config/visible';
232
+ export {
233
+ ControlMatrix,
234
+ type ControlMatrixColumn,
235
+ type ControlMatrixProps,
236
+ type ControlMatrixRow,
237
+ } from './controls/ControlMatrix';
231
238
  export { ControlPanel } from './controls/ControlPanel';
232
239
  export { type InDialogOptions, inDialog, summarizeValue } from './controls/inDialog';
233
240
  export type {
@@ -267,7 +274,6 @@ export * from './job';
267
274
  export * from './lab';
268
275
  export type { LayerListItem, LayerListProps, LayerMove } from './layers';
269
276
  export { LayerList, moveLayers } from './layers';
270
- export * from './loupe';
271
277
  export * from './primitives';
272
278
  export { SPECIMEN_SECTIONS, Specimen } from './specimen/Specimen';
273
279
  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);
@@ -1,4 +1,5 @@
1
1
  import { render, screen } from '@testing-library/react';
2
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
2
3
  import { describe, expect, it, vi } from 'vitest';
3
4
  import type { TrialContribution } from '../chrome/types';
4
5
  import type { Instrument } from '../instrument/types';
@@ -19,23 +20,27 @@ function silenceRenderError(): void {
19
20
  }
20
21
 
21
22
  describe('<Lab> chrome', () => {
22
- it('renders a consumer contribution', () => {
23
+ it('renders a consumer contribution', async () => {
23
24
  const extra: TrialContribution = {
24
25
  id: 'export',
25
26
  region: 'toolbar',
26
27
  item: { icon: Glyph, label: 'Export', onActivate: () => {} },
27
28
  };
28
- render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" chrome={[extra]} />);
29
+ await renderSettled(
30
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" chrome={[extra]} />,
31
+ );
29
32
  expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument();
30
33
  });
31
34
 
32
- it('suppresses a built-in by id', () => {
33
- render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" suppress={['snapshot']} />);
35
+ it('suppresses a built-in by id', async () => {
36
+ await renderSettled(
37
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" suppress={['snapshot']} />,
38
+ );
34
39
  expect(screen.queryByRole('button', { name: 'Save snapshot' })).toBeNull();
35
40
  expect(screen.getByRole('button', { name: 'Clone trial' })).toBeInTheDocument();
36
41
  });
37
42
 
38
- it('throws when a consumer id collides with a built-in', () => {
43
+ it('throws when a consumer id collides with a built-in', async () => {
39
44
  silenceRenderError();
40
45
  const clash: TrialContribution = {
41
46
  id: 'clone',
@@ -47,24 +52,24 @@ describe('<Lab> chrome', () => {
47
52
  ).toThrow(/duplicate contribution id "clone"/);
48
53
  });
49
54
 
50
- it('throws when suppressing an id that is not there', () => {
55
+ it('throws when suppressing an id that is not there', async () => {
51
56
  silenceRenderError();
52
57
  expect(() =>
53
58
  render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" suppress={['nope']} />),
54
59
  ).toThrow(/cannot suppress "nope"/);
55
60
  });
56
61
 
57
- it('renders an instrument-declared contribution', () => {
62
+ it('renders an instrument-declared contribution', async () => {
58
63
  const withChrome: Instrument = {
59
64
  ...bare,
60
65
  chrome: [{ id: 'mine', region: 'status', item: { text: 'ready' } }],
61
66
  };
62
- render(<Lab title="T" instruments={[withChrome]} defaultInstrument="Bare" />);
67
+ await renderSettled(<Lab title="T" instruments={[withChrome]} defaultInstrument="Bare" />);
63
68
  expect(screen.getByText('ready')).toBeInTheDocument();
64
69
  });
65
70
 
66
- it('renders an aside contribution in its own pane, after the workspace', () => {
67
- render(
71
+ it('renders an aside contribution in its own pane, after the workspace', async () => {
72
+ await renderSettled(
68
73
  <Lab
69
74
  title="T"
70
75
  instruments={[bare]}
@@ -1,4 +1,4 @@
1
- import { render } from '@testing-library/react';
1
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
2
2
  import { resolveTheme } from '@weasel-js/theme';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import type { Instrument } from '../instrument/types';
@@ -18,8 +18,8 @@ const backdrop = (container: HTMLElement) =>
18
18
  .find(Boolean) ?? '';
19
19
 
20
20
  describe('<Lab nebula>', () => {
21
- it('draws one blob per literal color', () => {
22
- const { container } = render(
21
+ it('draws one blob per literal color', async () => {
22
+ const { container } = await renderSettled(
23
23
  <Lab
24
24
  instruments={[bare]}
25
25
  defaultInstrument="Bare"
@@ -30,8 +30,8 @@ describe('<Lab nebula>', () => {
30
30
  expect(backdrop(container).match(/radial-gradient/g)).toHaveLength(3);
31
31
  });
32
32
 
33
- it('resolves a theme ramp against the lab’s theme', () => {
34
- const { container } = render(
33
+ it('resolves a theme ramp against the lab’s theme', async () => {
34
+ const { container } = await renderSettled(
35
35
  <Lab instruments={[bare]} defaultInstrument="Bare" mode="dark" nebula={{ ramp: 'swatch' }} />,
36
36
  );
37
37
  const sky = resolveTheme(interstellarTheme, { mode: 'dark' })['--wzl-swatch-sky'];
@@ -7,7 +7,8 @@
7
7
  * workspace: `.lk-lab__body > .lk-workspace { min-width: 0 }` is a direct-child
8
8
  * selector, so an extra element between them silently drops that rule.
9
9
  */
10
- import { act, render } from '@testing-library/react';
10
+ import { act } from '@testing-library/react';
11
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
11
12
  import type { ReactNode } from 'react';
12
13
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
13
14
  import { defineInstrument } from '../instrument/defineInstrument';
@@ -75,16 +76,18 @@ const probeInstrument = defineInstrument<Record<string, never>, Record<string, n
75
76
  });
76
77
 
77
78
  describe('<Lab> surface provider', () => {
78
- it('puts a surface handle in reach of an instrument', () => {
79
+ it('puts a surface handle in reach of an instrument', async () => {
79
80
  seen = 'not-rendered';
80
- render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
81
+ await renderSettled(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
81
82
  expect(seen).not.toBe('not-rendered');
82
83
  expect(seen).not.toBeNull();
83
84
  expect(typeof (seen as unknown as SurfaceHandle).registerTile).toBe('function');
84
85
  });
85
86
 
86
- it('stacks an under and an over buffer inside the lab body', () => {
87
- const { container } = render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
87
+ it('stacks an under and an over buffer inside the lab body', async () => {
88
+ const { container } = await renderSettled(
89
+ <Lab instruments={[probeInstrument]} defaultInstrument="Probe" />,
90
+ );
88
91
  const canvases = container.querySelectorAll('.lk-lab__body > canvas.lk-lab__surface');
89
92
  expect(canvases).toHaveLength(2);
90
93
  // Source order, because the z-index that separates them is in a stylesheet
@@ -96,24 +99,28 @@ describe('<Lab> surface provider', () => {
96
99
  ]);
97
100
  });
98
101
 
99
- it('hands a tenant whichever of the two it asks for', () => {
100
- const { container } = render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
102
+ it('hands a tenant whichever of the two it asks for', async () => {
103
+ const { container } = await renderSettled(
104
+ <Lab instruments={[probeInstrument]} defaultInstrument="Probe" />,
105
+ );
101
106
  const [underEl, overEl] = [...container.querySelectorAll('canvas.lk-lab__surface')];
102
107
  expect(seenCanvases.over).toBe(overEl);
103
108
  expect(seenCanvases.under).toBe(underEl);
104
109
  expect(overEl).not.toBe(underEl);
105
110
  });
106
111
 
107
- it('defaults a tenant to the buffer over the trials', () => {
112
+ it('defaults a tenant to the buffer over the trials', async () => {
108
113
  // Every tenant predating the split wants the over one: the surface was
109
114
  // built for marks that annotate an instrument.
110
- const { container } = render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
115
+ const { container } = await renderSettled(
116
+ <Lab instruments={[probeInstrument]} defaultInstrument="Probe" />,
117
+ );
111
118
  const [underEl, overEl] = [...container.querySelectorAll('canvas.lk-lab__surface')];
112
119
  expect(seenCanvases.byDefault).toBe(overEl);
113
120
  expect(seenCanvases.byDefault).not.toBe(underEl);
114
121
  });
115
122
 
116
- it('mounts no buffer of its own when a host already owns the surface', () => {
123
+ it('mounts no buffer of its own when a host already owns the surface', async () => {
117
124
  function Host({ children }: { children: ReactNode }) {
118
125
  const surface = useTiledSurface({ onFrame: () => {} });
119
126
  return (
@@ -122,7 +129,7 @@ describe('<Lab> surface provider', () => {
122
129
  </SurfaceContext.Provider>
123
130
  );
124
131
  }
125
- const { container } = render(
132
+ const { container } = await renderSettled(
126
133
  <Host>
127
134
  <Lab instruments={[probeInstrument]} defaultInstrument="Probe" />
128
135
  </Host>,
@@ -130,11 +137,13 @@ describe('<Lab> surface provider', () => {
130
137
  expect(container.querySelectorAll('canvas.lk-lab__surface')).toHaveLength(0);
131
138
  });
132
139
 
133
- it('keeps the workspace a direct child of the lab body', () => {
140
+ it('keeps the workspace a direct child of the lab body', async () => {
134
141
  // The min-width:0 rule that keeps the flex row from overflowing is
135
142
  // `.lk-lab__body > .lk-workspace`. An element inserted between them drops
136
143
  // it, and jsdom cannot see the overflow that follows.
137
- const { container } = render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
144
+ const { container } = await renderSettled(
145
+ <Lab instruments={[probeInstrument]} defaultInstrument="Probe" />,
146
+ );
138
147
  const body = container.querySelector('.lk-lab__body');
139
148
  expect(body).not.toBeNull();
140
149
  expect(body?.querySelector(':scope > .lk-workspace')).not.toBeNull();
@@ -155,7 +164,7 @@ describe('<Lab> tile round trip', () => {
155
164
  vi.restoreAllMocks();
156
165
  });
157
166
 
158
- it('publishes a rect for a tile an instrument registered', () => {
167
+ it('publishes a rect for a tile an instrument registered', async () => {
159
168
  const frames: SurfaceFrame[] = [];
160
169
  let handle: SurfaceHandle | null = null;
161
170
 
@@ -177,7 +186,7 @@ describe('<Lab> tile round trip', () => {
177
186
  );
178
187
  }
179
188
 
180
- render(
189
+ await renderSettled(
181
190
  <Host>
182
191
  <Lab instruments={[probeInstrument]} defaultInstrument="Probe" />
183
192
  </Host>,
@@ -189,11 +198,11 @@ describe('<Lab> tile round trip', () => {
189
198
  expect(pane).not.toBeNull();
190
199
  stubBox(pane, 240, 100, 320, 200);
191
200
 
192
- act(() => {
201
+ await act(async () => {
193
202
  handle?.invalidateRects();
194
203
  handle?.invalidate(paneTileId);
195
204
  });
196
- act(() => {
205
+ await act(async () => {
197
206
  vi.advanceTimersByTime(64);
198
207
  });
199
208
 
@@ -208,8 +217,8 @@ describe('<Lab> tile round trip', () => {
208
217
  });
209
218
 
210
219
  describe('<Lab> pages', () => {
211
- it("gives the title a way back to the project's other labs", () => {
212
- const { getByRole } = render(
220
+ it("gives the title a way back to the project's other labs", async () => {
221
+ const { getByRole } = await renderSettled(
213
222
  <Lab
214
223
  instruments={[probeInstrument]}
215
224
  defaultInstrument="Probe"
@@ -225,8 +234,8 @@ describe('<Lab> pages', () => {
225
234
  expect(getByRole('button', { name: /kliegsminister/ })).toBeTruthy();
226
235
  });
227
236
 
228
- it('leaves the title a plain heading when there are no other labs', () => {
229
- const { getByRole, queryByRole } = render(
237
+ it('leaves the title a plain heading when there are no other labs', async () => {
238
+ const { getByRole, queryByRole } = await renderSettled(
230
239
  <Lab instruments={[probeInstrument]} defaultInstrument="Probe" title="solo" />,
231
240
  );
232
241
  expect(getByRole('heading', { name: 'solo' })).toBeTruthy();