@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
@@ -1,4 +1,4 @@
1
- import { act, fireEvent, render, screen, within } from '@testing-library/react';
1
+ import { act, fireEvent, screen, within } from '@testing-library/react';
2
2
  import {
3
3
  ActionsProvider,
4
4
  type ActionsRegistry,
@@ -8,6 +8,7 @@ import {
8
8
  useActionsRegistry,
9
9
  useGestureDispatcher,
10
10
  } from '@weasel-js/core';
11
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
11
12
  import { type ReactNode, useEffect } from 'react';
12
13
  import { beforeAll, describe, expect, it, vi } from 'vitest';
13
14
  import { defineInstrument } from '../instrument/defineInstrument';
@@ -54,16 +55,16 @@ function Capture({ children }: { children?: ReactNode }) {
54
55
  const zoomBar = () => within(screen.getByRole('toolbar', { name: 'Zoom' }));
55
56
  const readout = () => zoomBar().getByRole('button', { name: /^Zoom \d+%|^Zoom unavailable/ });
56
57
  const press = (name: string) =>
57
- act(() => {
58
+ act(async () => {
58
59
  fireEvent.click(zoomBar().getByRole('button', { name }));
59
60
  });
60
61
 
61
62
  const MOD = /mac/i.test(navigator.platform || navigator.userAgent)
62
63
  ? { metaKey: true }
63
64
  : { ctrlKey: true };
64
- function key(k: string): boolean {
65
+ async function key(k: string): Promise<boolean> {
65
66
  let prevented = false;
66
- act(() => {
67
+ await act(async () => {
67
68
  prevented = !fireEvent.keyDown(window, { key: k, ...MOD });
68
69
  });
69
70
  return prevented;
@@ -111,113 +112,123 @@ function stageZoom(container: HTMLElement, index = 0): number {
111
112
  }
112
113
 
113
114
  describe("the lab header's zoom controls", () => {
114
- it('step the focused trial through its camera and read its zoom back', () => {
115
- const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
115
+ it('step the focused trial through its camera and read its zoom back', async () => {
116
+ const { container } = await renderSettled(
117
+ <Lab instruments={[staged]} defaultInstrument="Staged" />,
118
+ );
116
119
  expect(readout()).toHaveTextContent('100%');
117
- press('Zoom in');
120
+ await press('Zoom in');
118
121
  expect(stageZoom(container)).toBeCloseTo(1.25);
119
122
  expect(readout()).toHaveTextContent('125%');
120
- press('Zoom out');
123
+ await press('Zoom out');
121
124
  expect(stageZoom(container)).toBeCloseTo(1);
122
- press('Zoom out');
125
+ await press('Zoom out');
123
126
  expect(readout()).toHaveTextContent('80%');
124
127
  });
125
128
 
126
- it('reset to actual size from the readout', () => {
127
- const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
128
- press('Zoom out');
129
- press('Zoom out');
130
- act(() => {
129
+ it('reset to actual size from the readout', async () => {
130
+ const { container } = await renderSettled(
131
+ <Lab instruments={[staged]} defaultInstrument="Staged" />,
132
+ );
133
+ await press('Zoom out');
134
+ await press('Zoom out');
135
+ await act(async () => {
131
136
  fireEvent.click(readout());
132
137
  });
133
138
  expect(stageZoom(container)).toBe(1);
134
139
  expect(readout()).toHaveTextContent('100%');
135
140
  });
136
141
 
137
- it("stop at the camera's own limit, and say so", () => {
138
- const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
139
- press('Zoom in');
140
- press('Zoom in');
142
+ it("stop at the camera's own limit, and say so", async () => {
143
+ const { container } = await renderSettled(
144
+ <Lab instruments={[staged]} defaultInstrument="Staged" />,
145
+ );
146
+ await press('Zoom in');
147
+ await press('Zoom in');
141
148
  expect(stageZoom(container)).toBe(1.5);
142
149
  expect(zoomBar().getByRole('button', { name: 'Zoom in' })).toBeDisabled();
143
150
  expect(zoomBar().getByRole('button', { name: 'Zoom out' })).toBeEnabled();
144
151
  });
145
152
 
146
- it('answer Mod+=, Mod+- and Mod+0, claiming the key from the browser', () => {
147
- const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
148
- expect(key('=')).toBe(true);
153
+ it('answer Mod+=, Mod+- and Mod+0, claiming the key from the browser', async () => {
154
+ const { container } = await renderSettled(
155
+ <Lab instruments={[staged]} defaultInstrument="Staged" />,
156
+ );
157
+ expect(await key('=')).toBe(true);
149
158
  expect(stageZoom(container)).toBeCloseTo(1.25);
150
- key('-');
151
- key('-');
159
+ await key('-');
160
+ await key('-');
152
161
  expect(stageZoom(container)).toBeCloseTo(0.8);
153
- key('0');
162
+ await key('0');
154
163
  expect(stageZoom(container)).toBe(1);
155
164
  });
156
165
 
157
- it('go inert over a trial with no camera, and leave its keys to the browser', () => {
158
- render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
166
+ it('go inert over a trial with no camera, and leave its keys to the browser', async () => {
167
+ await renderSettled(<Lab instruments={[plain]} defaultInstrument="Plain" />);
159
168
  for (const name of ['Zoom out', 'Zoom in']) {
160
169
  expect(zoomBar().getByRole('button', { name })).toBeDisabled();
161
170
  }
162
171
  expect(readout()).toBeDisabled();
163
172
  expect(readout()).toHaveTextContent('–');
164
- expect(key('=')).toBe(false);
173
+ expect(await key('=')).toBe(false);
165
174
  });
166
175
 
167
- it("take the key ahead of a story's own binding in a trial with a camera", () => {
176
+ it("take the key ahead of a story's own binding in a trial with a camera", async () => {
168
177
  const story = vi.fn();
169
178
  const zooming = defineInstrument<Record<string, never>, Record<string, never>>({
170
179
  ...staged,
171
180
  name: 'Zooming',
172
181
  render: () => <StoryZoomKey run={story} />,
173
182
  });
174
- const { container } = render(<Lab instruments={[zooming]} defaultInstrument="Zooming" />);
175
- key('=');
183
+ const { container } = await renderSettled(
184
+ <Lab instruments={[zooming]} defaultInstrument="Zooming" />,
185
+ );
186
+ await key('=');
176
187
  expect(stageZoom(container)).toBeCloseTo(1.25);
177
188
  expect(story).not.toHaveBeenCalled();
178
189
  });
179
190
 
180
- it("leave the key to a story's own binding in a trial with no camera", () => {
191
+ it("leave the key to a story's own binding in a trial with no camera", async () => {
181
192
  const story = vi.fn();
182
193
  const zooming = defineInstrument<Record<string, never>, Record<string, never>>({
183
194
  ...plain,
184
195
  name: 'ZoomingPlain',
185
196
  render: () => <StoryZoomKey run={story} />,
186
197
  });
187
- render(<Lab instruments={[zooming]} defaultInstrument="ZoomingPlain" />);
188
- key('=');
198
+ await renderSettled(<Lab instruments={[zooming]} defaultInstrument="ZoomingPlain" />);
199
+ await key('=');
189
200
  expect(story).toHaveBeenCalledTimes(1);
190
201
  });
191
202
 
192
- it('follow the focus from trial to trial', () => {
193
- const { container } = render(
203
+ it('follow the focus from trial to trial', async () => {
204
+ const { container } = await renderSettled(
194
205
  <Lab instruments={[staged, plain]} defaultInstrument="Staged">
195
206
  <Capture />
196
207
  </Lab>,
197
208
  );
198
- act(() => lab?.addTrial('Staged'));
199
- act(() => lab?.addTrial('Plain'));
209
+ await act(async () => lab?.addTrial('Staged'));
210
+ await act(async () => lab?.addTrial('Plain'));
200
211
  const [first, second, third] = lab?.trials.map((t) => t.id) ?? [];
201
212
  expect(zoomBar().getByRole('button', { name: 'Zoom in' })).toBeDisabled();
202
213
 
203
- act(() => lab?.focusTrial(second ?? ''));
204
- press('Zoom out');
214
+ await act(async () => lab?.focusTrial(second ?? ''));
215
+ await press('Zoom out');
205
216
  expect(stageZoom(container, 1)).toBeCloseTo(0.8);
206
217
  expect(stageZoom(container, 0)).toBe(1);
207
218
  expect(readout()).toHaveTextContent('80%');
208
219
 
209
- act(() => lab?.focusTrial(first ?? ''));
220
+ await act(async () => lab?.focusTrial(first ?? ''));
210
221
  expect(readout()).toHaveTextContent('100%');
211
- key('=');
222
+ await key('=');
212
223
  expect(stageZoom(container, 0)).toBeCloseTo(1.25);
213
224
  expect(stageZoom(container, 1)).toBeCloseTo(0.8);
214
225
 
215
- act(() => lab?.focusTrial(third ?? ''));
226
+ await act(async () => lab?.focusTrial(third ?? ''));
216
227
  expect(readout()).toBeDisabled();
217
228
  });
218
229
 
219
- it('are left out when the lab says so', () => {
220
- render(<Lab instruments={[staged]} defaultInstrument="Staged" zoom={false} />);
230
+ it('are left out when the lab says so', async () => {
231
+ await renderSettled(<Lab instruments={[staged]} defaultInstrument="Staged" zoom={false} />);
221
232
  expect(screen.queryByRole('toolbar', { name: 'Zoom' })).toBeNull();
222
233
  });
223
234
  });
@@ -10,10 +10,11 @@ import {
10
10
  useActionsRegistry,
11
11
  useDepSource,
12
12
  useGestureDispatcher,
13
+ useLatest,
13
14
  type ViewApi,
14
15
  } from '@weasel-js/core';
15
16
  import { ZoomInIcon, ZoomOutIcon } from '@weasel-js/ui';
16
- import { useContext, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
17
+ import { useContext, useEffect, useMemo, useSyncExternalStore } from 'react';
17
18
  import { useStore } from 'zustand/react';
18
19
  import type { CameraView } from '../canvas/CameraInput';
19
20
  import { CameraRegistryContext } from '../canvas/cameraRegistry';
@@ -96,8 +97,7 @@ function LabZoomControls() {
96
97
 
97
98
  const live = zoom !== null && camera !== null;
98
99
  const view = useMemo(() => (camera ? forZoom(camera) : null), [camera]);
99
- const viewRef = useRef(view);
100
- viewRef.current = live ? view : null;
100
+ const viewRef = useLatest(live ? view : null);
101
101
 
102
102
  useDepSource('view', () => viewRef.current as ViewApi);
103
103
 
@@ -108,7 +108,7 @@ function LabZoomControls() {
108
108
  enabled: () => (viewRef.current ? true : ActionDisabledReason.NotApplicable),
109
109
  };
110
110
  return registry.register(action);
111
- }, [registry]);
111
+ }, [registry, viewRef]);
112
112
 
113
113
  // First, so a story's own zoom keys do not also fire over a trial with a
114
114
  // camera; over one without, the action is disabled and the keys pass on.
@@ -1,4 +1,5 @@
1
- import { render } from '@testing-library/react';
1
+ import { act } 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 { SurfaceContext } from '../surface/SurfaceContext';
4
5
  import type { SurfaceHandle } from '../surface/useTiledSurface';
@@ -18,9 +19,9 @@ function fakeHandle(): SurfaceHandle {
18
19
  }
19
20
 
20
21
  describe('Workspace with a surface above it', () => {
21
- it('invalidates rects when the tile set changes, because a re-tile moves tiles', () => {
22
+ it('invalidates rects when the tile set changes, because a re-tile moves tiles', async () => {
22
23
  const handle = fakeHandle();
23
- const { rerender } = render(
24
+ const { rerender } = await renderSettled(
24
25
  <SurfaceContext.Provider value={handle}>
25
26
  <Workspace ids={['a']} viewport={{ w: 800, h: 600 }}>
26
27
  <div>a</div>
@@ -29,20 +30,22 @@ describe('Workspace with a surface above it', () => {
29
30
  );
30
31
  (handle.invalidateRects as ReturnType<typeof vi.fn>).mockClear();
31
32
 
32
- rerender(
33
- <SurfaceContext.Provider value={handle}>
34
- <Workspace ids={['a', 'b']} viewport={{ w: 800, h: 600 }}>
35
- <div>a</div>
36
- <div>b</div>
37
- </Workspace>
38
- </SurfaceContext.Provider>,
39
- );
33
+ await act(async () => {
34
+ rerender(
35
+ <SurfaceContext.Provider value={handle}>
36
+ <Workspace ids={['a', 'b']} viewport={{ w: 800, h: 600 }}>
37
+ <div>a</div>
38
+ <div>b</div>
39
+ </Workspace>
40
+ </SurfaceContext.Provider>,
41
+ );
42
+ });
40
43
 
41
44
  expect(handle.invalidateRects).toHaveBeenCalled();
42
45
  });
43
46
 
44
- it('renders unchanged with no surface above it', () => {
45
- const { getByText } = render(
47
+ it('renders unchanged with no surface above it', async () => {
48
+ const { getByText } = await renderSettled(
46
49
  <Workspace ids={['a']} viewport={{ w: 800, h: 600 }}>
47
50
  <div>a</div>
48
51
  </Workspace>,
@@ -1,4 +1,5 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { act, screen } from '@testing-library/react';
2
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
2
3
  import { describe, expect, test, vi } from 'vitest';
3
4
  import { useTrialDrag } from '../trial/TrialDragContext';
4
5
  import { Workspace } from './Workspace';
@@ -13,8 +14,8 @@ function DragProbe({ label }: { label: string }) {
13
14
  const VIEWPORT = { w: 800, h: 600 };
14
15
 
15
16
  describe('Workspace', () => {
16
- test('renders all children', () => {
17
- render(
17
+ test('renders all children', async () => {
18
+ await renderSettled(
18
19
  <Workspace viewport={VIEWPORT}>
19
20
  <div>one</div>
20
21
  <div>two</div>
@@ -26,9 +27,9 @@ describe('Workspace', () => {
26
27
  expect(screen.getByText('three')).toBeInTheDocument();
27
28
  });
28
29
 
29
- test('keeps a child with the tile its id names when an earlier one closes', () => {
30
+ test('keeps a child with the tile its id names when an earlier one closes', async () => {
30
31
  const ids = ['a', 'b', 'c'];
31
- const { rerender, container } = render(
32
+ const { rerender, container } = await renderSettled(
32
33
  <Workspace ids={ids} viewport={VIEWPORT}>
33
34
  <div>a</div>
34
35
  <div>b</div>
@@ -37,18 +38,20 @@ describe('Workspace', () => {
37
38
  );
38
39
  expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
39
40
 
40
- rerender(
41
- <Workspace ids={['b', 'c']} viewport={VIEWPORT}>
42
- <div>b</div>
43
- <div>c</div>
44
- </Workspace>,
45
- );
41
+ await act(async () => {
42
+ rerender(
43
+ <Workspace ids={['b', 'c']} viewport={VIEWPORT}>
44
+ <div>b</div>
45
+ <div>c</div>
46
+ </Workspace>,
47
+ );
48
+ });
46
49
  expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
47
50
  expect(container.querySelector('[data-node="a"]')).toBeNull();
48
51
  });
49
52
 
50
- test('uses lk-workspace class', () => {
51
- const { container } = render(
53
+ test('uses lk-workspace class', async () => {
54
+ const { container } = await renderSettled(
52
55
  <Workspace viewport={VIEWPORT}>
53
56
  <div />
54
57
  </Workspace>,
@@ -56,8 +59,8 @@ describe('Workspace', () => {
56
59
  expect((container.firstChild as HTMLElement).className).toContain('lk-workspace');
57
60
  });
58
61
 
59
- test('renders resize affordances only when resizable', () => {
60
- const { container, rerender } = render(
62
+ test('renders resize affordances only when resizable', async () => {
63
+ const { container, rerender } = await renderSettled(
61
64
  <Workspace ids={['a', 'b']} viewport={VIEWPORT}>
62
65
  <div>a</div>
63
66
  <div>b</div>
@@ -65,17 +68,19 @@ describe('Workspace', () => {
65
68
  );
66
69
  expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
67
70
 
68
- rerender(
69
- <Workspace ids={['a', 'b']} resizable viewport={VIEWPORT}>
70
- <div>a</div>
71
- <div>b</div>
72
- </Workspace>,
73
- );
71
+ await act(async () => {
72
+ rerender(
73
+ <Workspace ids={['a', 'b']} resizable viewport={VIEWPORT}>
74
+ <div>a</div>
75
+ <div>b</div>
76
+ </Workspace>,
77
+ );
78
+ });
74
79
  expect(container.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0);
75
80
  });
76
81
 
77
- test('applies a saved extent to a tile as it registers', () => {
78
- const { container } = render(
82
+ test('applies a saved extent to a tile as it registers', async () => {
83
+ const { container } = await renderSettled(
79
84
  <Workspace
80
85
  ids={['a', 'b']}
81
86
  resizable
@@ -92,9 +97,9 @@ describe('Workspace', () => {
92
97
  expect(Number.parseFloat(a.style.width)).toBeGreaterThan(Number.parseFloat(b.style.width));
93
98
  });
94
99
 
95
- test('reports the order a drop would produce instead of applying it', () => {
100
+ test('reports the order a drop would produce instead of applying it', async () => {
96
101
  const onReorder = vi.fn();
97
- const { container } = render(
102
+ const { container } = await renderSettled(
98
103
  <Workspace ids={['a', 'b']} reorderable onReorder={onReorder} viewport={VIEWPORT}>
99
104
  <DragProbe label="a" />
100
105
  <DragProbe label="b" />
@@ -111,8 +116,8 @@ describe('Workspace', () => {
111
116
  expect(nodes).toEqual(['a', 'b']);
112
117
  });
113
118
 
114
- test('offers no drag source unless reorderable', () => {
115
- render(
119
+ test('offers no drag source unless reorderable', async () => {
120
+ await renderSettled(
116
121
  <Workspace ids={['a', 'b']} viewport={VIEWPORT}>
117
122
  <DragProbe label="a" />
118
123
  <DragProbe label="b" />
@@ -1,3 +1,4 @@
1
+ import { useLatest } from '@weasel-js/core';
1
2
  import {
2
3
  Children,
3
4
  type ReactNode,
@@ -33,6 +34,7 @@ export type TrialLayout = Record<
33
34
  { size?: { w?: number; h?: number }; span?: { cols?: number; rows?: number } }
34
35
  >;
35
36
 
37
+ /** Props for `Workspace`. */
36
38
  export interface WorkspaceProps {
37
39
  children: ReactNode;
38
40
  /**
@@ -61,7 +63,7 @@ export interface WorkspaceProps {
61
63
  padding?: number;
62
64
  /** Undocked sidebar panels to render alongside the trials. A `'tile'` panel
63
65
  * joins the grid as a peer of the trials; a `'floating'` one goes into the
64
- * floating zone above it. The body is portalled in by the trial that owns
66
+ * floating zone above it. The body is portaled in by the trial that owns
65
67
  * it, so all this renders is the frame and the host. */
66
68
  panels?: readonly PanelDescriptor[];
67
69
  /**
@@ -72,6 +74,26 @@ export interface WorkspaceProps {
72
74
  viewport?: { w: number; h: number };
73
75
  }
74
76
 
77
+ /** The layout store a workspace keeps for its lifetime: the tile grid, and the zone floating panels live in. */
78
+ function createWorkspaceStore(grid: { resizable: boolean; gap: number; padding: number }): Store {
79
+ const store = new Store();
80
+ store.registerNode(
81
+ createNode({
82
+ kind: 'zone',
83
+ id: ZONE_ID,
84
+ container: { strategyId: 'grid', config: grid },
85
+ }),
86
+ );
87
+ store.registerNode(
88
+ createNode({
89
+ kind: 'zone',
90
+ id: FLOAT_ZONE_ID,
91
+ container: { strategyId: 'floating', config: {} },
92
+ }),
93
+ );
94
+ return store;
95
+ }
96
+
75
97
  function extentOf(store: Store, id: NodeId): TrialLayout[string] | null {
76
98
  const p = store.getNode(id)?.membership?.placement as TrialLayout[string] | undefined;
77
99
  if (!p) return null;
@@ -81,21 +103,6 @@ function extentOf(store: Store, id: NodeId): TrialLayout[string] | null {
81
103
  return Object.keys(out).length > 0 ? out : null;
82
104
  }
83
105
 
84
- /**
85
- * Auto-balanced tiling of trials, `ceil(sqrt(n))` columns wide.
86
- *
87
- * Tiles are absolutely positioned at the rects `gridStrategy` computes, not
88
- * laid out by CSS — `windease/styles.css` (folded into
89
- * `@weasel-js/labkit/styles.css`) carries the rules that positioning depends on.
90
- *
91
- * `panels` are undocked sidebar sections. A `'tile'` panel is registered as a
92
- * peer of the trials under `PANEL_KIND`, so the grid places and resizes it like
93
- * one; a `'floating'` panel goes in the overlay above. Either way this renders
94
- * only the frame and an empty host: the section's content is portalled in by
95
- * the trial that owns it, which is what keeps a torn-out section inside its
96
- * trial's React tree instead of rebuilding it as a sibling.
97
- */
98
-
99
106
  /** The frame an undocked panel gets: a box, and the host element its trial
100
107
  * portals into. The title and the dock control come through the portal with
101
108
  * the body — the workspace knows a panel's key, not what is in it. */
@@ -116,6 +123,20 @@ export interface PanelDescriptor {
116
123
  as: 'tile' | 'floating';
117
124
  }
118
125
 
126
+ /**
127
+ * Auto-balanced tiling of trials, `ceil(sqrt(n))` columns wide.
128
+ *
129
+ * Tiles are absolutely positioned at the rects `gridStrategy` computes, not
130
+ * laid out by CSS — `windease/styles.css` (folded into
131
+ * `@weasel-js/labkit/styles.css`) carries the rules that positioning depends on.
132
+ *
133
+ * `panels` are undocked sidebar sections. A `'tile'` panel is registered as a
134
+ * peer of the trials under `PANEL_KIND`, so the grid places and resizes it like
135
+ * one; a `'floating'` panel goes in the overlay above. Either way this renders
136
+ * only the frame and an empty host: the section's content is portaled in by
137
+ * the trial that owns it, which is what keeps a torn-out section inside its
138
+ * trial's React tree instead of rebuilding it as a sibling.
139
+ */
119
140
  export function Workspace({
120
141
  children,
121
142
  ids,
@@ -134,50 +155,25 @@ export function Workspace({
134
155
  const floatPanels = useMemo(() => (panels ?? []).filter((p) => p.as === 'floating'), [panels]);
135
156
  // Identity has to be stable while the id *contents* are unchanged: the sync
136
157
  // effect below keys off it, and a fresh array every render would re-register
137
- // every tile forever. A ref keyed on the joined ids says that directly, where
138
- // a `useMemo` on the same key can only say it by suppressing both linters.
139
- const wantedIds = [
158
+ // every tile forever. So the ids are memoized on a serialized key, and read
159
+ // back from it.
160
+ const idKey = JSON.stringify([
140
161
  ...items.map((_, i) => ids?.[i] ?? `lk-ws-${i}`),
141
162
  ...tilePanels.map((p) => `lk-panel-${p.key}`),
142
- ];
143
- const idKey = wantedIds.join(',');
144
- const idKeyRef = useRef<string | null>(null);
145
- const nodeIdsRef = useRef<NodeId[]>([]);
146
- if (idKeyRef.current !== idKey) {
147
- idKeyRef.current = idKey;
148
- nodeIdsRef.current = wantedIds.map(asNodeId);
149
- }
150
- const nodeIds = nodeIdsRef.current;
163
+ ]);
164
+ const nodeIds = useMemo(() => (JSON.parse(idKey) as string[]).map(asNodeId), [idKey]);
151
165
 
152
166
  // Held in refs rather than depended on: a fresh object each render would
153
167
  // re-run the sync effect, and only a newly registered tile reads `layout`.
154
- const layoutRef = useRef(layout);
155
- layoutRef.current = layout;
156
- const onLayoutChangeRef = useRef(onLayoutChange);
157
- onLayoutChangeRef.current = onLayoutChange;
168
+ const layoutRef = useLatest(layout);
169
+ const onLayoutChangeRef = useLatest(onLayoutChange);
158
170
 
159
171
  // One store for the component's lifetime: a tile's dragged extent lives in
160
172
  // its node, so rebuilding the store on every add or close would silently
161
173
  // reset every pane.
162
174
  const storeRef = useRef<Store | null>(null);
163
- if (storeRef.current === null) {
164
- const store = new Store();
165
- store.registerNode(
166
- createNode({
167
- kind: 'zone',
168
- id: ZONE_ID,
169
- container: { strategyId: 'grid', config: { resizable, gap, padding } },
170
- }),
171
- );
172
- store.registerNode(
173
- createNode({
174
- kind: 'zone',
175
- id: FLOAT_ZONE_ID,
176
- container: { strategyId: 'floating', config: {} },
177
- }),
178
- );
179
- storeRef.current = store;
180
- }
175
+ if (storeRef.current === null)
176
+ storeRef.current = createWorkspaceStore({ resizable, gap, padding });
181
177
  const store = storeRef.current;
182
178
 
183
179
  // A tile that only moves reports nothing to a ResizeObserver, and only this
@@ -203,7 +199,7 @@ export function Workspace({
203
199
  if (saved) store.patchPlacement(id, saved);
204
200
  }
205
201
  store.setChildOrder(ZONE_ID, [...nodeIds]);
206
- }, [store, nodeIds]);
202
+ }, [store, nodeIds, layoutRef]);
207
203
 
208
204
  // biome-ignore lint/correctness/useExhaustiveDependencies: nodeIds is the signal that the tile set changed — a re-tile moves tiles without resizing any — not a value this reads
209
205
  useEffect(() => {
@@ -222,7 +218,7 @@ export function Workspace({
222
218
  }
223
219
  onLayoutChangeRef.current?.(next);
224
220
  });
225
- }, [store, onLayoutChange]);
221
+ }, [store, onLayoutChange, onLayoutChangeRef]);
226
222
 
227
223
  const commitOrder = useCallback(
228
224
  (nextIds: NodeId[]) => onReorder?.(nextIds.map(String)),
@@ -1,7 +1,9 @@
1
1
  # Loupe — Agent Guide
2
2
 
3
- `src/loupe/` is the `loupe` instrument capability: a magnifier a trial can turn
4
- on, painted by whichever painter suits the instrument's content.
3
+ `src/loupe/` is `@weasel-js/labkit/loupe`: `<TrialLoupe>`, a magnifier the
4
+ instrument mounts in its own content, painted by whichever painter suits it.
5
+ Its own bundle entry; nothing in the main bundle imports it, so a lab that
6
+ mounts no lens never loads it.
5
7
 
6
8
  The magnifier itself is not here. `@weasel-js/loupe` holds the model — aim,
7
9
  factor, mode, color, picking — over a `LoupeSurface` it asks five questions.
@@ -11,29 +13,40 @@ This directory binds that model to a labkit trial and draws it.
11
13
 
12
14
  | File | Role |
13
15
  |---|---|
14
- | `types.ts` | `LoupeCapability`, and `resolveLoupe` filling in every default |
16
+ | `types.ts` | `LoupeOptions`, and `resolveLoupe` filling in every default |
15
17
  | `useLoupe.ts` | The model over a host element, and pointer aiming |
16
18
  | `loupeActions.ts` | `loupe.peek` and `loupe.magnify`, as `Action` descriptors |
17
19
  | `LoupeGestures.tsx` | Registers those, and mounts a dispatcher on the host when no camera has one there |
18
- | `TrialLoupe.tsx` | Picks the painter and mounts the lens |
20
+ | `TrialLoupe.tsx` | Finds its host and the trial's switch, picks the painter, mounts the lens |
19
21
  | `LoupeBubble.tsx` | The circular clip, positioned on the aim |
20
22
  | `CanvasLoupe.tsx` | Painter for a `<CanvasStack>` |
21
23
  | `canvasLens.ts` | That painter's geometry and drawing, with no React in it |
22
24
  | `DomLoupe.tsx` | Painter for DOM content |
23
25
  | `useHostSize.ts` | The host's measured box, for the DOM stage |
24
26
 
25
- ## Which painter
27
+ ## How it reads the trial
28
+
29
+ Only through context, the way `<TrialOverview>` does. `CanvasStackContext`'s
30
+ `surface` gives the canvas painter the stack's layers, pixels and `worldSpec`;
31
+ `CameraContext` gives a lens in a `<Stage>` its element and camera. Outside
32
+ both, it tracks `hostRef`, or failing that wraps its `children` in a
33
+ `.lk-loupe-host` box of its own.
26
34
 
27
- `LoupeCapability.render` decides. Absent, the lens re-runs the instrument's own
28
- canvas layers through `lensCamera` — sharp at any factor, and `mode: 'pixel'`
29
- enlarges the presented pixels with smoothing off instead. Present, the
30
- instrument is handed a camera and draws itself again; a DOM loupe is always
31
- `vector`, since DOM has no framebuffer to enlarge.
35
+ The toolbar toggle is `LoupeSwitchContext` (`src/trial/loupeSwitch.ts`, main
36
+ bundle). A lens with no `enabled` prop calls `mount` and follows `on`; the
37
+ trial offers its Loupe toggle while any lens is mounted. `enabled` opts a lens
38
+ out of the switch entirely. Because the lens mounts after the trial's first
39
+ render, `loupe` is in `TRANSIENT_BUILTINS`, so suppressing it before then is
40
+ not a typo.
41
+
42
+ ## Which painter
32
43
 
33
- The canvas painter needs the stack's own pixels and layers, which is why
34
- `TrialLoupe` mounts *inside* `<CanvasStack>` for a drawing instrument and reads
35
- `CanvasStackContext`'s `surface`. A DOM instrument gets a
36
- `.lk-trial__loupe-host` wrapper from `Trial` and the lens tracks that.
44
+ `render` decides. Absent, the lens re-runs the stack's canvas layers through
45
+ `lensCamera` — sharp at any factor, and `mode: 'pixel'` enlarges the presented
46
+ pixels with smoothing off instead. Present, it is handed a camera and draws the
47
+ content again; a DOM loupe is always `vector`, since DOM has no framebuffer to
48
+ enlarge. The canvas painter needs the stack's own pixels, which is why a
49
+ drawing instrument mounts the lens in its `render`, inside `<CanvasStack>`.
37
50
 
38
51
  ## Traps
39
52
 
@@ -51,7 +64,7 @@ In a trial the loupe's actions join the camera's dispatcher (`CameraInput`).
51
64
  `loupe.magnify` sits in the hotkey tier, so while the lens is up it outranks
52
65
  the camera's wheel zoom; while it is down its `enabled` returns a disabled
53
66
  reason and the dispatcher falls through to the zoom. Outside a camera — the DOM
54
- `.lk-trial__loupe-host` — `<LoupeGestures>` mounts a dispatcher of its own.
67
+ `.lk-loupe-host` — `<LoupeGestures>` mounts a dispatcher of its own.
55
68
 
56
69
  **Aiming is a plain listener because a hover is not a gesture.**
57
70
  `GESTURE_DESCRIPTORS` names no continuous-motion gesture, so `pointermove` /
@@ -1,4 +1,4 @@
1
- import { useVisibleRaf } from '@weasel-js/core';
1
+ import { useLatest, useVisibleRaf } from '@weasel-js/core';
2
2
  import type { LoupeMode, LoupePoint } from '@weasel-js/loupe';
3
3
  import { useEffect, useRef } from 'react';
4
4
  import type { CanvasStackSurface } from '../canvas/CanvasStackContext';
@@ -40,8 +40,7 @@ export function CanvasLoupe({
40
40
  // Whatever the stack redraws every frame, the lens has to redraw too — an
41
41
  // instrument that animates from its own loop never re-renders this component.
42
42
  const args = { aim, factor, mode, diameter, dpr, view, frame, worldSpec, surface };
43
- const argsRef = useRef(args);
44
- argsRef.current = args;
43
+ const argsRef = useLatest(args);
45
44
 
46
45
  const loop = useVisibleRaf(
47
46
  () => {