@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,5 @@
1
- import { act, fireEvent, render, screen, within } from '@testing-library/react';
1
+ import { act, fireEvent, screen, within } from '@testing-library/react';
2
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
2
3
  import { useState } from 'react';
3
4
  import { beforeAll, describe, expect, it, vi } from 'vitest';
4
5
  import { defineInstrument } from '../instrument/defineInstrument';
@@ -47,8 +48,8 @@ describe('fitStage', () => {
47
48
  });
48
49
 
49
50
  describe('<Stage>', () => {
50
- it('draws its content through the camera', () => {
51
- const { container } = render(
51
+ it('draws its content through the camera', async () => {
52
+ const { container } = await renderSettled(
52
53
  <Stage
53
54
  size={{ width: 300, height: 100 }}
54
55
  view={{ zoom: 2, pan: { x: 10, y: 20 } }}
@@ -66,7 +67,7 @@ describe('<Stage>', () => {
66
67
  expect(el.style.getPropertyValue('--lk-stage-h')).toBe('100px');
67
68
  });
68
69
 
69
- it('zooms on the wheel', () => {
70
+ it('zooms on the wheel', async () => {
70
71
  function Host() {
71
72
  const [view, setView] = useState<ViewTransform>({ zoom: 1, pan: { x: 0, y: 0 } });
72
73
  return (
@@ -75,14 +76,14 @@ describe('<Stage>', () => {
75
76
  </Stage>
76
77
  );
77
78
  }
78
- const { container } = render(<Host />);
79
+ const { container } = await renderSettled(<Host />);
79
80
  const host = container.querySelector('.lk-stage');
80
81
  if (!host) throw new Error('no stage');
81
82
  fireEvent.wheel(host, { deltaY: -200, clientX: 0, clientY: 0 });
82
83
  expect(zoomOf(content(container))).toBeGreaterThan(1);
83
84
  });
84
85
 
85
- it('zooms on a wheel handed to its camera from outside the stage', () => {
86
+ it('zooms on a wheel handed to its camera from outside the stage', async () => {
86
87
  // What an annotation target's input box does: it is portalled out of the
87
88
  // stage, so its wheel never bubbles here.
88
89
  const slot: CameraWheelSlot = { current: null };
@@ -96,19 +97,19 @@ describe('<Stage>', () => {
96
97
  </CameraWheelContext.Provider>
97
98
  );
98
99
  }
99
- const { container } = render(<Host />);
100
- act(() => {
100
+ const { container } = await renderSettled(<Host />);
101
+ await act(async () => {
101
102
  slot.current?.(new WheelEvent('wheel', { deltaY: -200, cancelable: true }));
102
103
  });
103
104
  expect(zoomOf(content(container))).toBeGreaterThan(1);
104
105
  });
105
106
 
106
- it('listens for the wheel actively, so it can keep the page from scrolling', () => {
107
+ it('listens for the wheel actively, so it can keep the page from scrolling', async () => {
107
108
  // A proxy: jsdom does not enforce passive listeners, so a preventDefault in
108
109
  // a passive one succeeds here and fails in a browser. React registers its
109
110
  // wheel listeners passive; asserting the registration is what can fail.
110
111
  const add = vi.spyOn(HTMLElement.prototype, 'addEventListener');
111
- const { container } = render(
112
+ const { container } = await renderSettled(
112
113
  <Stage
113
114
  size={{ width: 100, height: 100 }}
114
115
  view={{ zoom: 1, pan: { x: 0, y: 0 } }}
@@ -127,7 +128,7 @@ describe('<Stage>', () => {
127
128
  add.mockRestore();
128
129
  });
129
130
 
130
- it('tells the surface its tiles moved when the camera does', () => {
131
+ it('tells the surface its tiles moved when the camera does', async () => {
131
132
  // A transform moves a tile without resizing anything, which is the one
132
133
  // kind of move a ResizeObserver never reports.
133
134
  const invalidateRects = vi.fn();
@@ -139,7 +140,7 @@ describe('<Stage>', () => {
139
140
  </Stage>
140
141
  </SurfaceContext.Provider>
141
142
  );
142
- const { rerender } = render(at({ zoom: 1, pan: { x: 0, y: 0 } }));
143
+ const { rerender } = await renderSettled(at({ zoom: 1, pan: { x: 0, y: 0 } }));
143
144
  invalidateRects.mockClear();
144
145
  rerender(at({ zoom: 1.5, pan: { x: 0, y: 0 } }));
145
146
  expect(invalidateRects).toHaveBeenCalled();
@@ -162,25 +163,29 @@ const plain = defineInstrument<Record<string, never>, Record<string, never>>({
162
163
  });
163
164
 
164
165
  describe('an instrument that declares a stage', () => {
165
- it('renders its DOM on the stage', () => {
166
- const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
166
+ it('renders its DOM on the stage', async () => {
167
+ const { container } = await renderSettled(
168
+ <Lab instruments={[staged]} defaultInstrument="Staged" />,
169
+ );
167
170
  expect(content(container)).toContainElement(screen.getByTestId('art'));
168
171
  });
169
172
 
170
- it('gets the zoom controls, and they move the stage', () => {
171
- const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
173
+ it('gets the zoom controls, and they move the stage', async () => {
174
+ const { container } = await renderSettled(
175
+ <Lab instruments={[staged]} defaultInstrument="Staged" />,
176
+ );
172
177
  const trialView = within(screen.getByRole('toolbar', { name: 'View' }));
173
- act(() => {
178
+ await act(async () => {
174
179
  fireEvent.click(trialView.getByRole('button', { name: 'Zoom in' }));
175
180
  });
176
181
  expect(zoomOf(content(container))).toBeCloseTo(1.25);
177
- act(() => {
182
+ await act(async () => {
178
183
  fireEvent.click(trialView.getByRole('button', { name: 'Actual size' }));
179
184
  });
180
185
  expect(zoomOf(content(container))).toBe(1);
181
186
  });
182
187
 
183
- it('zooms about the middle of the view, not the stage origin', () => {
188
+ it('zooms about the middle of the view, not the stage origin', async () => {
184
189
  const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
185
190
  x: 0,
186
191
  y: 0,
@@ -193,9 +198,11 @@ describe('an instrument that declares a stage', () => {
193
198
  toJSON: () => ({}),
194
199
  });
195
200
  try {
196
- const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
201
+ const { container } = await renderSettled(
202
+ <Lab instruments={[staged]} defaultInstrument="Staged" />,
203
+ );
197
204
  const trialView = within(screen.getByRole('toolbar', { name: 'View' }));
198
- act(() => {
205
+ await act(async () => {
199
206
  fireEvent.click(trialView.getByRole('button', { name: 'Zoom in' }));
200
207
  });
201
208
  const el = content(container);
@@ -208,12 +215,12 @@ describe('an instrument that declares a stage', () => {
208
215
  }
209
216
  });
210
217
 
211
- it('is what earns them: DOM content without one gets none', () => {
212
- render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
218
+ it('is what earns them: DOM content without one gets none', async () => {
219
+ await renderSettled(<Lab instruments={[plain]} defaultInstrument="Plain" />);
213
220
  expect(screen.queryByRole('toolbar', { name: 'View' })).toBeNull();
214
221
  });
215
222
 
216
- it('draws its overlay on the stage, outside the camera', () => {
223
+ it('draws its overlay on the stage, outside the camera', async () => {
217
224
  const overlaid = defineInstrument<{ n: number }, Record<string, never>>({
218
225
  name: 'Overlaid',
219
226
  defaultConfig: () => ({}),
@@ -224,7 +231,9 @@ describe('an instrument that declares a stage', () => {
224
231
  overlay: ({ state }) => <div data-testid="legend">{state.n}</div>,
225
232
  },
226
233
  });
227
- const { container } = render(<Lab instruments={[overlaid]} defaultInstrument="Overlaid" />);
234
+ const { container } = await renderSettled(
235
+ <Lab instruments={[overlaid]} defaultInstrument="Overlaid" />,
236
+ );
228
237
  const legend = screen.getByTestId('legend');
229
238
  expect(legend).toHaveTextContent('7');
230
239
  expect(container.querySelector('.lk-stage')).toContainElement(legend);
@@ -1,4 +1,5 @@
1
1
  import type { Yoke } from '@weasel-js/core';
2
+ import { useLatest } from '@weasel-js/core';
2
3
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
3
4
  import type { ViewTransform } from '../instrument/types';
4
5
  import { normalize2DView } from '../state/view';
@@ -85,8 +86,7 @@ export function Stage({
85
86
  [camera, size.width, size.height],
86
87
  );
87
88
 
88
- const onResizeRef = useRef(onResize);
89
- onResizeRef.current = onResize;
89
+ const onResizeRef = useLatest(onResize);
90
90
  useEffect(() => {
91
91
  const el = host.current;
92
92
  if (!el) return;
@@ -101,7 +101,7 @@ export function Stage({
101
101
  const ro = new ResizeObserver(measure);
102
102
  ro.observe(el);
103
103
  return () => ro.disconnect();
104
- }, []);
104
+ }, [onResizeRef]);
105
105
 
106
106
  // A transform moves every tile inside the content without resizing anything,
107
107
  // and a ResizeObserver reports only resizes.
@@ -15,6 +15,7 @@ export interface CameraRegistry {
15
15
  subscribe(listener: () => void): () => void;
16
16
  }
17
17
 
18
+ /** An empty in-memory `CameraRegistry`. */
18
19
  export function createCameraRegistry(): CameraRegistry {
19
20
  const stacks = new Map<string, CameraView[]>();
20
21
  const listeners = new Set<() => void>();
@@ -5,7 +5,7 @@ import type { Point, ViewTransform } from '../instrument/types';
5
5
  * world (0,0) at the element's top-left, y growing downward. */
6
6
  export interface WorldSpec {
7
7
  /** Where world (0,0) sits at `pan` zero, as a fraction of the viewport.
8
- * `{x:0,y:0}` top-left (default); `{x:0.5,y:0.5}` centre. */
8
+ * `{x:0,y:0}` top-left (default); `{x:0.5,y:0.5}` center. */
9
9
  origin?: Point;
10
10
  /** Which way the world y axis runs on screen. Default `'down'`. */
11
11
  yAxis?: 'down' | 'up';
@@ -25,8 +25,10 @@ export interface WorldFrame {
25
25
  yDir: 1 | -1;
26
26
  }
27
27
 
28
+ /** The frame an absent `WorldSpec` resolves to: origin top-left, y down. */
28
29
  export const DEFAULT_FRAME: WorldFrame = { originPx: { x: 0, y: 0 }, yDir: 1 };
29
30
 
31
+ /** Resolve `spec` against a viewport of `size`; `undefined` gives `DEFAULT_FRAME`. */
30
32
  export function resolveFrame(spec: WorldSpec | undefined, size: ViewportSize): WorldFrame {
31
33
  if (!spec) return DEFAULT_FRAME;
32
34
  const origin = spec.origin ?? { x: 0, y: 0 };
@@ -23,6 +23,7 @@ const ctx: TrialChromeContext = {
23
23
  canRedo: false,
24
24
  undo: () => {},
25
25
  redo: () => {},
26
+ hasLoupe: false,
26
27
  loupeOn: false,
27
28
  toggleLoupe: () => {},
28
29
  configFields: [],
@@ -72,21 +73,21 @@ describe('builtinContributions', () => {
72
73
  expect(ids({ ...bare, undo: {} })).toContain('redo');
73
74
  });
74
75
 
75
- it('contributes the loupe toggle only when the instrument declares one', () => {
76
+ it('contributes the loupe toggle only while a lens is mounted', () => {
76
77
  expect(ids(bare)).not.toContain('loupe');
77
- expect(ids({ ...bare, loupe: true })).toContain('loupe');
78
- expect(ids({ ...bare, loupe: { render: () => null } })).toContain('loupe');
78
+ const mounted = builtinContributions(bare, { ...ctx, hasLoupe: true });
79
+ expect(mounted.map((c) => c.id)).toContain('loupe');
79
80
  });
80
81
 
81
82
  it('offers the loupe to a DOM instrument, which declares no canvas', () => {
82
- const contributions = builtinContributions({ ...bare, loupe: true }, ctx);
83
+ const contributions = builtinContributions(bare, { ...ctx, hasLoupe: true });
83
84
  const loupe = contributions.find((c) => c.id === 'loupe');
84
85
  expect(loupe?.region).toBe('toolbar');
85
86
  });
86
87
 
87
88
  it('reports the loupe switch position, so the button reads as held', () => {
88
- const off = builtinContributions({ ...bare, loupe: true }, ctx);
89
- const on = builtinContributions({ ...bare, loupe: true }, { ...ctx, loupeOn: true });
89
+ const off = builtinContributions(bare, { ...ctx, hasLoupe: true });
90
+ const on = builtinContributions(bare, { ...ctx, hasLoupe: true, loupeOn: true });
90
91
  expect(off.find((c) => c.id === 'loupe')?.item).toMatchObject({ pressed: false });
91
92
  expect(on.find((c) => c.id === 'loupe')?.item).toMatchObject({ pressed: true });
92
93
  });
@@ -39,6 +39,10 @@ function groupFolds(
39
39
  return out;
40
40
  }
41
41
 
42
+ /** Built-in ids contributed only while runtime state says so — the loupe
43
+ * toggle appears once a lens mounts, after the trial's first render. */
44
+ export const TRANSIENT_BUILTINS: ReadonlySet<string> = new Set(['loupe']);
45
+
42
46
  /**
43
47
  * The contributions a trial gets from what its instrument declared. This is
44
48
  * the whole "declaring a capability provides the chrome" rule — it replaces
@@ -93,7 +97,7 @@ export function builtinContributions(
93
97
  });
94
98
  }
95
99
 
96
- if (instrument.loupe != null) {
100
+ if (ctx.hasLoupe) {
97
101
  out.push({
98
102
  id: 'loupe',
99
103
  region: 'toolbar',
@@ -1,4 +1,4 @@
1
- export { builtinContributions } from './builtins';
1
+ export { builtinContributions, TRANSIENT_BUILTINS } from './builtins';
2
2
  export type { LabRegionProps } from './LabChrome';
3
3
  export {
4
4
  contributionsIn,
@@ -1,4 +1,5 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { act, fireEvent, 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 { Instrument } from '../instrument/types';
4
5
  import { Lab } from '../lab/Lab';
@@ -19,19 +20,19 @@ function silenceRenderError(): void {
19
20
  }
20
21
 
21
22
  describe('lab-level chrome', () => {
22
- it('renders a header contribution beside the lab header', () => {
23
+ it('renders a header contribution beside the lab header', async () => {
23
24
  const contribution: LabContribution = {
24
25
  id: 'publish',
25
26
  region: 'header',
26
27
  item: { icon: Glyph, label: 'Publish', onActivate: () => {} },
27
28
  };
28
- render(
29
+ await renderSettled(
29
30
  <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
30
31
  );
31
32
  expect(screen.getByRole('button', { name: 'Publish' })).toBeInTheDocument();
32
33
  });
33
34
 
34
- it('hands a header contribution the lab, not a trial', () => {
35
+ it('hands a header contribution the lab, not a trial', async () => {
35
36
  const contribution: LabContribution = {
36
37
  id: 'another',
37
38
  region: 'header',
@@ -41,21 +42,23 @@ describe('lab-level chrome', () => {
41
42
  onActivate: (ctx) => ctx.addTrial('Bare'),
42
43
  },
43
44
  };
44
- render(
45
+ await renderSettled(
45
46
  <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
46
47
  );
47
48
  expect(screen.getAllByLabelText(/^Trial /)).toHaveLength(1);
48
- fireEvent.click(screen.getByRole('button', { name: 'Another' }));
49
+ await act(async () => {
50
+ fireEvent.click(screen.getByRole('button', { name: 'Another' }));
51
+ });
49
52
  expect(screen.getAllByLabelText(/^Trial /)).toHaveLength(2);
50
53
  });
51
54
 
52
- it('renders a palette contribution in the lab rail and selects it', () => {
55
+ it('renders a palette contribution in the lab rail and selects it', async () => {
53
56
  const contribution: LabContribution = {
54
57
  id: 'measure',
55
58
  region: 'palette',
56
59
  item: { icon: Glyph, label: 'Measure' },
57
60
  };
58
- render(
61
+ await renderSettled(
59
62
  <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
60
63
  );
61
64
  const button = screen.getByRole('button', { name: 'Measure' });
@@ -63,7 +66,7 @@ describe('lab-level chrome', () => {
63
66
  expect(screen.getByRole('button', { name: 'Measure' })).toHaveAttribute('aria-current', 'true');
64
67
  });
65
68
 
66
- it('gives a palette contribution the whole lab context, not a tool slot alone', () => {
69
+ it('gives a palette contribution the whole lab context, not a tool slot alone', async () => {
67
70
  // The shape the `as unknown as TrialChromeContext` cast used to fake: a
68
71
  // contribution reading anything past the tool slot got `undefined`.
69
72
  const seen: LabChromeContext[] = [];
@@ -75,7 +78,7 @@ describe('lab-level chrome', () => {
75
78
  return <span>{`${ctx.trials.length} trial(s), mode ${ctx.mode}`}</span>;
76
79
  },
77
80
  };
78
- render(
81
+ await renderSettled(
79
82
  <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
80
83
  );
81
84
  expect(screen.getByText('1 trial(s), mode auto')).toBeInTheDocument();
@@ -83,19 +86,19 @@ describe('lab-level chrome', () => {
83
86
  expect(typeof seen[0]?.saveSnapshot).toBe('function');
84
87
  });
85
88
 
86
- it('renders a footer contribution', () => {
89
+ it('renders a footer contribution', async () => {
87
90
  const contribution: LabContribution = {
88
91
  id: 'count',
89
92
  region: 'footer',
90
93
  item: { text: 'idle' },
91
94
  };
92
- render(
95
+ await renderSettled(
93
96
  <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
94
97
  );
95
98
  expect(screen.getByText('idle')).toBeInTheDocument();
96
99
  });
97
100
 
98
- it('throws when two lab contributions share an id', () => {
101
+ it('throws when two lab contributions share an id', async () => {
99
102
  silenceRenderError();
100
103
  const clash: LabContribution[] = [
101
104
  { id: 'same', region: 'header', item: { icon: Glyph, label: 'One', onActivate: () => {} } },
@@ -106,7 +109,7 @@ describe('lab-level chrome', () => {
106
109
  ).toThrow(/duplicate contribution id "same"/);
107
110
  });
108
111
 
109
- it('keeps the lab tool slot and lab palette contributions in one namespace', () => {
112
+ it('keeps the lab tool slot and lab palette contributions in one namespace', async () => {
110
113
  silenceRenderError();
111
114
  const clash: LabContribution = {
112
115
  id: 'pick',
@@ -134,8 +137,8 @@ describe('lab sidebar region', () => {
134
137
  item: { title: 'Stories', body: <p>story list</p> },
135
138
  };
136
139
 
137
- it('renders a section beside the workspace', () => {
138
- const { container } = render(
140
+ it('renders a section beside the workspace', async () => {
141
+ const { container } = await renderSettled(
139
142
  <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />,
140
143
  );
141
144
  expect(screen.getByText('story list')).toBeInTheDocument();
@@ -143,18 +146,22 @@ describe('lab sidebar region', () => {
143
146
  expect(screen.getByRole('separator', { name: /lab sidebar/i })).toBeInTheDocument();
144
147
  });
145
148
 
146
- it('folds a section', () => {
147
- render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />);
149
+ it('folds a section', async () => {
150
+ await renderSettled(
151
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />,
152
+ );
148
153
  fireEvent.click(screen.getByRole('button', { name: 'Stories' }));
149
154
  expect(screen.queryByText('story list')).toBeNull();
150
155
  });
151
156
 
152
- it('offers no tear-out at lab level', () => {
153
- render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />);
157
+ it('offers no tear-out at lab level', async () => {
158
+ await renderSettled(
159
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />,
160
+ );
154
161
  expect(screen.queryByRole('button', { name: 'Undock Stories' })).toBeNull();
155
162
  });
156
163
 
157
- it('hands a render contribution the lab context', () => {
164
+ it('hands a render contribution the lab context', async () => {
158
165
  const seen: LabChromeContext[] = [];
159
166
  const probe: LabContribution = {
160
167
  id: 'probe',
@@ -164,13 +171,17 @@ describe('lab sidebar region', () => {
164
171
  return <p>probe</p>;
165
172
  },
166
173
  };
167
- render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[probe]} />);
174
+ await renderSettled(
175
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[probe]} />,
176
+ );
168
177
  expect(screen.getByText('probe')).toBeInTheDocument();
169
178
  expect(seen[0]?.trials).toHaveLength(1);
170
179
  });
171
180
 
172
- it('adds no strip to a lab without sidebar chrome', () => {
173
- const { container } = render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" />);
181
+ it('adds no strip to a lab without sidebar chrome', async () => {
182
+ const { container } = await renderSettled(
183
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" />,
184
+ );
174
185
  expect(container.querySelector('.lk-lab__panes')).toBeNull();
175
186
  });
176
187
  });
@@ -182,7 +193,7 @@ describe('lab chrome without <Lab>', () => {
182
193
  undo: () => void;
183
194
  }
184
195
 
185
- it('renders a contribution typed against a consumer context and hands it back', () => {
196
+ it('renders a contribution typed against a consumer context and hands it back', async () => {
186
197
  const undo = vi.fn();
187
198
  const ctx: RailCtx = { undo };
188
199
  const rail: readonly LabContribution<RailCtx>[] = [
@@ -192,7 +203,7 @@ describe('lab chrome without <Lab>', () => {
192
203
  item: { icon: Glyph, label: 'Undo', showLabel: true, onActivate: (c) => c.undo() },
193
204
  },
194
205
  ];
195
- render(
206
+ await renderSettled(
196
207
  <LabShell
197
208
  title="Palette lab"
198
209
  header={
@@ -44,6 +44,11 @@ describe('suppressContributions', () => {
44
44
  );
45
45
  });
46
46
 
47
+ it('passes over an absent id it was told may come and go', () => {
48
+ const out = suppressContributions([toolbarItem('a')], ['late'], new Set(['late']));
49
+ expect(out.map((c) => c.id)).toEqual(['a']);
50
+ });
51
+
47
52
  it('is a no-op for an empty suppress list', () => {
48
53
  const bundle = [toolbarItem('a')];
49
54
  expect(suppressContributions(bundle, [])).toEqual(bundle);
@@ -30,15 +30,17 @@ export function mergeContributions<T extends { id: string }>(
30
30
  /**
31
31
  * Drop contributions by id. Throws when an id is not present: a typo that
32
32
  * silently suppresses nothing is the same class of bug as a duplicate id
33
- * silently winning.
33
+ * silently winning. `transient` names ids that are legitimately absent at
34
+ * times — contributed only while some runtime state holds.
34
35
  */
35
36
  export function suppressContributions<T extends { id: string }>(
36
37
  bundle: readonly T[],
37
38
  ids: readonly string[],
39
+ transient: ReadonlySet<string> = new Set(),
38
40
  ): T[] {
39
41
  const present = new Set(bundle.map((c) => c.id));
40
42
  for (const id of ids) {
41
- if (!present.has(id)) {
43
+ if (!present.has(id) && !transient.has(id)) {
42
44
  throw new Error(
43
45
  `[labkit] cannot suppress "${id}": no contribution with that id. ` +
44
46
  `Present ids: ${[...present].join(', ')}`,
@@ -1,4 +1,4 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
3
  import type { RegionContribution, ToolSlotContext } from '../types';
4
4
  import { PaletteRegion } from './PaletteRegion';
@@ -62,16 +62,15 @@ describe('PaletteRegion', () => {
62
62
 
63
63
  it('walks the vertical strip with ArrowDown, and leaves ArrowRight to the page', () => {
64
64
  render(<PaletteRegion contributions={[tool('brush'), tool('eraser')]} ctx={ctxWith(null)} />);
65
- const strip = screen.getByRole('toolbar', { name: 'Tools' });
66
65
  const brush = screen.getByRole('button', { name: 'brush' });
67
66
  const eraser = screen.getByRole('button', { name: 'eraser' });
68
67
 
69
- brush.focus();
70
- fireEvent.keyDown(strip, { key: 'ArrowDown' });
68
+ act(() => brush.focus());
69
+ fireEvent.keyDown(brush, { key: 'ArrowDown' });
71
70
  expect(document.activeElement).toBe(eraser);
72
71
  expect(eraser).toHaveAttribute('tabindex', '0');
73
72
 
74
- fireEvent.keyDown(strip, { key: 'ArrowRight' });
73
+ fireEvent.keyDown(eraser, { key: 'ArrowRight' });
75
74
  expect(document.activeElement).toBe(eraser);
76
75
  });
77
76
  });
@@ -1,5 +1,4 @@
1
- import { ToolButton, ToolGroup } from '../../passthrough/weasel-ui';
2
- import { useRovingTabIndex } from '../../primitives/useRovingTabIndex';
1
+ import { ToolButton, ToolGroup, useRovingTabIndex } from '../../passthrough/weasel-ui';
3
2
  import type { RegionContribution, ToolItem, ToolSlotContext, TrialChromeContext } from '../types';
4
3
 
5
4
  /** Props for `<PaletteRegion>`. */
@@ -19,11 +18,11 @@ export function PaletteRegion<TCtx extends ToolSlotContext = TrialChromeContext>
19
18
  ctx,
20
19
  region = 'palette',
21
20
  }: PaletteRegionProps<TCtx>) {
22
- const { ref, onKeyDown } = useRovingTabIndex<HTMLDivElement>('vertical');
21
+ const { rootRef, onKeyDown } = useRovingTabIndex<HTMLDivElement>({ orientation: 'vertical' });
23
22
  if (contributions.length === 0) return null;
24
23
  return (
25
24
  <div
26
- ref={ref}
25
+ ref={rootRef}
27
26
  className="lk-palette-region"
28
27
  role="toolbar"
29
28
  aria-label="Tools"
@@ -0,0 +1,6 @@
1
+ // Narrower than the zoom control's 160px natural width, wider than its floor.
2
+ .lk-viewport-test-well {
3
+ position: relative;
4
+ width: 160px;
5
+ height: 120px;
6
+ }
@@ -0,0 +1,38 @@
1
+ import '@weasel-js/theme/tokens.css';
2
+ import '../../styles.less';
3
+ import './ViewportRegion.browser.test.less';
4
+ import { cleanup, render } from '@testing-library/react';
5
+ import { afterEach, expect, test } from 'vitest';
6
+ import { ZoomControl } from '../../primitives/ZoomControl';
7
+ import type { TrialChromeContext, TrialContribution } from '../types';
8
+ import { ViewportRegion } from './ViewportRegion';
9
+
10
+ // Whether a rendered control shrinks to the cluster or runs out past it is
11
+ // layout, which only a real browser has.
12
+
13
+ afterEach(cleanup);
14
+
15
+ const ctx = { zoom: 1, setZoom: () => {} } as unknown as TrialChromeContext;
16
+
17
+ const contributions: TrialContribution[] = [
18
+ {
19
+ id: 'zoom-control',
20
+ region: 'viewport',
21
+ render: (c) => <ZoomControl zoom={c.zoom ?? 1} onZoomChange={c.setZoom} />,
22
+ },
23
+ ];
24
+
25
+ test('a zoom control on its own row shrinks its slider to fit a narrow well', () => {
26
+ const { container } = render(
27
+ <div className="lk-root">
28
+ <div className="lk-viewport-test-well">
29
+ <ViewportRegion contributions={contributions} ctx={ctx} />
30
+ </div>
31
+ </div>,
32
+ );
33
+ const cluster = container.querySelector('.lk-viewport-controls')?.getBoundingClientRect();
34
+ const zoom = container.querySelector('.lk-zoom')?.getBoundingClientRect();
35
+ if (!cluster || !zoom) throw new Error('nothing rendered');
36
+ expect(zoom.left).toBeGreaterThanOrEqual(cluster.left);
37
+ expect(zoom.right).toBeLessThanOrEqual(cluster.right);
38
+ });
@@ -17,6 +17,12 @@
17
17
  backdrop-filter: blur(var(--wzl-glass-blur));
18
18
  }
19
19
 
20
+ // Without this a rendered control's floor is its content's min-content, and the
21
+ // zoom slider's fixed width then runs it out past the cluster on a narrow well.
22
+ .lk-viewport-controls__item {
23
+ min-width: 0;
24
+ }
25
+
20
26
  .lk-viewport-controls__button {
21
27
  display: inline-flex;
22
28
  align-items: center;
@@ -1,4 +1,4 @@
1
- import { useRovingTabIndex } from '../../primitives/useRovingTabIndex';
1
+ import { useRovingTabIndex } from '../../passthrough/weasel-ui';
2
2
  import type { TrialChromeContext, TrialContribution } from '../types';
3
3
 
4
4
  /** Props for `<ViewportRegion>`. */
@@ -12,18 +12,24 @@ export interface ViewportRegionProps {
12
12
  * rather than in the toolbar, which acts on the trial itself.
13
13
  */
14
14
  export function ViewportRegion({ contributions, ctx }: ViewportRegionProps) {
15
- const { ref, onKeyDown } = useRovingTabIndex<HTMLDivElement>();
15
+ const { rootRef, onKeyDown } = useRovingTabIndex<HTMLDivElement>({ orientation: 'horizontal' });
16
16
  if (contributions.length === 0) return null;
17
17
  return (
18
18
  <div
19
- ref={ref}
19
+ ref={rootRef}
20
20
  className="lk-viewport-controls"
21
21
  role="toolbar"
22
22
  aria-label="View"
23
23
  onKeyDown={onKeyDown}
24
24
  >
25
25
  {contributions.map((c) => {
26
- if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
26
+ if (c.render) {
27
+ return (
28
+ <span key={c.id} className="lk-viewport-controls__item">
29
+ {c.render(ctx)}
30
+ </span>
31
+ );
32
+ }
27
33
  if (c.region !== 'viewport' || !c.item) return null;
28
34
  const { icon: Icon, label, disabled, onActivate } = c.item;
29
35
  return (
@@ -162,8 +162,10 @@ export interface TrialChromeContext extends ToolSlotContext, SidebarSlotContext
162
162
  undo: () => void;
163
163
  redo: () => void;
164
164
 
165
- /** Whether the trial's loupe is turned on. False for an instrument that
166
- * declares none, whose chrome offers no way to turn one on. */
165
+ /** Whether a `<TrialLoupe>` following the trial's toggle is mounted in it.
166
+ * The loupe toggle is offered only while one is. */
167
+ hasLoupe: boolean;
168
+ /** Whether the trial's loupe is turned on. */
167
169
  loupeOn: boolean;
168
170
  toggleLoupe: () => void;
169
171