@weasel-js/labkit 1.4.4 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. package/README.md +54 -6
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
  7. package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
  8. package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
  9. package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
  10. package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
  11. package/dist/canvas/index.d.ts +18 -129
  12. package/dist/canvas/index.js +4 -2
  13. package/dist/chrome/index.d.ts +5 -93
  14. package/dist/chrome/index.js +10 -6
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  26. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  27. package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
  28. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  29. package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
  30. package/dist/chunk-IJJUK4FU.js.map +1 -0
  31. package/dist/chunk-IXTQI7JX.js +737 -0
  32. package/dist/chunk-IXTQI7JX.js.map +1 -0
  33. package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
  34. package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
  35. package/dist/chunk-PHK4W562.js +72 -0
  36. package/dist/chunk-PHK4W562.js.map +1 -0
  37. package/dist/chunk-PJ7WEQBJ.js +18 -0
  38. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  39. package/dist/chunk-RWNDDKOH.js +682 -0
  40. package/dist/chunk-RWNDDKOH.js.map +1 -0
  41. package/dist/chunk-T3EC2DXD.js +12628 -0
  42. package/dist/chunk-T3EC2DXD.js.map +1 -0
  43. package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
  44. package/dist/chunk-UN4U4P63.js.map +1 -0
  45. package/dist/chunk-WIG6XHJ7.js +35 -0
  46. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  47. package/dist/chunk-Z76KEZAX.js +15 -0
  48. package/dist/chunk-Z76KEZAX.js.map +1 -0
  49. package/dist/chunk-ZDHY47EV.js +363 -0
  50. package/dist/chunk-ZDHY47EV.js.map +1 -0
  51. package/dist/config/index.d.ts +276 -0
  52. package/dist/config/index.js +8 -0
  53. package/dist/config/index.js.map +1 -0
  54. package/dist/controls/index.d.ts +3 -4
  55. package/dist/controls/index.js +4 -3
  56. package/dist/dragdrop/index.d.ts +3 -5
  57. package/dist/index.d.ts +266 -413
  58. package/dist/index.js +1775 -2251
  59. package/dist/index.js.map +1 -1
  60. package/dist/layers/index.d.ts +4 -6
  61. package/dist/layers/index.js +2 -2
  62. package/dist/loupe/index.d.ts +69 -20
  63. package/dist/loupe/index.js +2 -2
  64. package/dist/passthrough/weasel-ui.d.ts +175 -309
  65. package/dist/passthrough/weasel-ui.js +1 -1
  66. package/dist/primitives/index.d.ts +55 -6
  67. package/dist/primitives/index.js +6 -4
  68. package/dist/state/index.d.ts +13 -8
  69. package/dist/state/index.js +18 -13
  70. package/dist/state/index.js.map +1 -1
  71. package/dist/styles.css +223 -76
  72. package/dist/surface/index.d.ts +44 -7
  73. package/dist/surface/index.js +2 -1
  74. package/dist/ui/layers/index.js +1 -1
  75. package/dist/undo/index.d.ts +3 -5
  76. package/package.json +15 -8
  77. package/src/annotations/AnnotationOverlay.tsx +56 -5
  78. package/src/annotations/Annotations.less +4 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +105 -6
  80. package/src/annotations/drawOne.test.ts +1 -1
  81. package/src/annotations/drawOne.ts +2 -2
  82. package/src/annotations/paint.test.ts +2 -2
  83. package/src/annotations/paint.ts +1 -1
  84. package/src/annotations/preload.ts +6 -0
  85. package/src/annotations/store.test.ts +18 -0
  86. package/src/annotations/store.ts +5 -4
  87. package/src/annotations/svgNodes.test.ts +2 -2
  88. package/src/annotations/svgNodes.ts +1 -0
  89. package/src/annotations/toolMap.test.ts +9 -5
  90. package/src/annotations/toolMap.ts +6 -3
  91. package/src/annotations/types.ts +6 -3
  92. package/src/canvas/CameraWheelContext.ts +12 -0
  93. package/src/canvas/CanvasStack.stories.tsx +1 -1
  94. package/src/canvas/CanvasStack.test.tsx +25 -0
  95. package/src/canvas/CanvasStack.tsx +31 -4
  96. package/src/canvas/Stage.less +18 -0
  97. package/src/canvas/Stage.test.tsx +186 -0
  98. package/src/canvas/Stage.tsx +134 -0
  99. package/src/canvas/camera.test.ts +17 -2
  100. package/src/canvas/camera.ts +9 -9
  101. package/src/canvas/index.ts +4 -0
  102. package/src/canvas/usePanZoom.test.ts +28 -0
  103. package/src/canvas/usePanZoom.ts +21 -9
  104. package/src/chrome/ChromeRegions.stories.tsx +1 -4
  105. package/src/chrome/LabChrome.tsx +130 -0
  106. package/src/chrome/builtins.test.ts +1 -0
  107. package/src/chrome/builtins.tsx +21 -31
  108. package/src/chrome/index.ts +16 -0
  109. package/src/chrome/labRegions.test.tsx +208 -0
  110. package/src/chrome/labTypes.ts +50 -0
  111. package/src/chrome/merge.ts +7 -9
  112. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  113. package/src/chrome/regions/PaletteRegion.tsx +16 -9
  114. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  115. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  116. package/src/chrome/regions/StatusRegion.tsx +15 -8
  117. package/src/chrome/regions/ToolbarRegion.tsx +34 -16
  118. package/src/chrome/types.ts +50 -20
  119. package/src/config/auto.test.ts +12 -0
  120. package/src/config/auto.ts +15 -0
  121. package/src/config/autoConfig.test.ts +107 -0
  122. package/src/config/autoConfig.ts +153 -0
  123. package/src/config/builder.test.ts +42 -0
  124. package/src/config/builder.ts +68 -17
  125. package/src/config/declarationEmit.fixture.ts +11 -0
  126. package/src/config/declarationEmit.test.ts +51 -0
  127. package/src/config/entry.test.ts +46 -0
  128. package/src/config/index.ts +16 -1
  129. package/src/config/nodeClasses.test.ts +18 -0
  130. package/src/config/path.ts +7 -2
  131. package/src/config/resolve.test.ts +25 -0
  132. package/src/config/resolve.ts +17 -1
  133. package/src/config/types.ts +34 -2
  134. package/src/config/useResolvedConfig.ts +26 -0
  135. package/src/controls/AutoControls.stories.tsx +88 -0
  136. package/src/controls/ControlPanel.stories.tsx +42 -1
  137. package/src/controls/ControlPanel.test.tsx +530 -4
  138. package/src/controls/ControlPanel.tsx +562 -82
  139. package/src/fake-indexeddb-auto.d.ts +5 -0
  140. package/src/index.test.ts +7 -0
  141. package/src/index.ts +66 -5
  142. package/src/instrument/serializers.test.ts +22 -0
  143. package/src/instrument/serializers.ts +40 -1
  144. package/src/instrument/types.ts +25 -1
  145. package/src/lab/Lab.chrome.test.tsx +16 -0
  146. package/src/lab/Lab.less +4 -13
  147. package/src/lab/Lab.persist.test.tsx +147 -0
  148. package/src/lab/Lab.stories.tsx +1 -3
  149. package/src/lab/Lab.surface.test.tsx +71 -3
  150. package/src/lab/Lab.test.tsx +168 -16
  151. package/src/lab/Lab.tsx +434 -129
  152. package/src/lab/LabContext.ts +8 -0
  153. package/src/lab/LabFit.stories.less +18 -0
  154. package/src/lab/LabFit.stories.tsx +219 -0
  155. package/src/lab/LabFullChrome.stories.tsx +1 -4
  156. package/src/lab/LabHeader.test.tsx +40 -9
  157. package/src/lab/LabHeader.tsx +7 -14
  158. package/src/lab/LabPalette.tsx +13 -26
  159. package/src/lab/LabRoot.test.tsx +56 -0
  160. package/src/lab/LabRoot.tsx +47 -0
  161. package/src/lab/LabShell.less +73 -15
  162. package/src/lab/LabShell.stories.tsx +50 -1
  163. package/src/lab/LabShell.tsx +18 -27
  164. package/src/lab/LabSwitcher.less +18 -11
  165. package/src/lab/LabSwitcher.test.tsx +14 -9
  166. package/src/lab/LabSwitcher.tsx +8 -4
  167. package/src/lab/Workspace.less +1 -1
  168. package/src/lab/Workspace.stories.tsx +1 -1
  169. package/src/lab/Workspace.surface.test.tsx +1 -0
  170. package/src/lab/fitCheck.test.ts +57 -0
  171. package/src/lab/fitCheck.ts +125 -0
  172. package/src/lab/index.ts +2 -0
  173. package/src/layers/LayerList.stories.tsx +1 -1
  174. package/src/loupe/AGENTS.md +17 -5
  175. package/src/loupe/Loupe.less +1 -1
  176. package/src/loupe/LoupeGestures.tsx +67 -0
  177. package/src/loupe/TrialLoupe.tsx +43 -28
  178. package/src/loupe/canvasLens.ts +1 -1
  179. package/src/loupe/index.ts +4 -0
  180. package/src/loupe/loupeActions.ts +98 -0
  181. package/src/loupe/types.ts +1 -1
  182. package/src/loupe/useLoupe.test.tsx +60 -3
  183. package/src/loupe/useLoupe.ts +29 -49
  184. package/src/passthrough/weasel-ui.ts +9 -0
  185. package/src/primitives/FloatingPanel.stories.tsx +14 -2
  186. package/src/primitives/FloatingPanel.test.tsx +47 -16
  187. package/src/primitives/FloatingPanel.tsx +11 -25
  188. package/src/primitives/FpsMeter.stories.tsx +1 -1
  189. package/src/primitives/JobProgress.stories.tsx +1 -1
  190. package/src/primitives/Legend.stories.tsx +1 -1
  191. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  192. package/src/primitives/Sidebar.less +7 -0
  193. package/src/primitives/Sidebar.stories.tsx +1 -1
  194. package/src/primitives/Split.less +14 -0
  195. package/src/primitives/Split.test.tsx +133 -0
  196. package/src/primitives/Split.tsx +175 -0
  197. package/src/primitives/StatusBar.stories.tsx +1 -1
  198. package/src/primitives/Toolbar.stories.tsx +1 -1
  199. package/src/primitives/ZoomControl.tsx +5 -2
  200. package/src/primitives/index.ts +2 -0
  201. package/src/specimen/Specimen.less +95 -0
  202. package/src/specimen/Specimen.stories.tsx +13 -0
  203. package/src/specimen/Specimen.test.tsx +13 -0
  204. package/src/specimen/Specimen.tsx +888 -0
  205. package/src/state/Persistence.stories.tsx +75 -0
  206. package/src/state/Persistence.tsx +33 -0
  207. package/src/state/SingletonExperiment.test.tsx +47 -53
  208. package/src/state/SingletonExperiment.tsx +24 -13
  209. package/src/state/adapterContract.ts +116 -0
  210. package/src/state/adapters.test.ts +113 -61
  211. package/src/state/adapters.ts +328 -69
  212. package/src/state/document.test.ts +71 -96
  213. package/src/state/document.ts +66 -52
  214. package/src/state/helpers.test.ts +1 -1
  215. package/src/state/index.ts +8 -0
  216. package/src/state/labRecords.test.ts +105 -0
  217. package/src/state/labRecords.ts +160 -0
  218. package/src/state/openLabStore.test.ts +658 -0
  219. package/src/state/openLabStore.ts +403 -0
  220. package/src/state/records.test.ts +234 -0
  221. package/src/state/records.ts +219 -0
  222. package/src/state/store.test.ts +169 -635
  223. package/src/state/store.ts +146 -163
  224. package/src/state/toolSlot.test.ts +1 -1
  225. package/src/state/types.ts +65 -17
  226. package/src/state/useOpenOnce.ts +68 -0
  227. package/src/state/usePersistedState.test.tsx +125 -0
  228. package/src/state/usePersistedState.ts +66 -0
  229. package/src/state/useTrialState.test.tsx +87 -3
  230. package/src/state/useTrialState.ts +9 -1
  231. package/src/state/view.test.ts +49 -15
  232. package/src/state/view.ts +18 -1
  233. package/src/styles.less +3 -0
  234. package/src/surface/AGENTS.md +39 -7
  235. package/src/surface/SurfaceContext.ts +26 -3
  236. package/src/surface/index.ts +2 -0
  237. package/src/surface/useSurfaceTile.test.tsx +1 -0
  238. package/src/surface/useSurfaceTile.ts +6 -5
  239. package/src/surface/useTiledSurface.test.tsx +231 -1
  240. package/src/surface/useTiledSurface.ts +76 -15
  241. package/src/theme/Interstellar.stories.tsx +9 -9
  242. package/src/theme/interstellar.test.ts +17 -6
  243. package/src/theme/interstellar.theme.json +180 -0
  244. package/src/theme/interstellar.ts +8 -5
  245. package/src/tools/labTool.ts +11 -0
  246. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  247. package/src/trial/Trial.annotations.test.tsx +59 -7
  248. package/src/trial/Trial.canvas.test.tsx +32 -0
  249. package/src/trial/Trial.config.test.tsx +75 -2
  250. package/src/trial/Trial.job.test.tsx +3 -3
  251. package/src/trial/Trial.less +5 -26
  252. package/src/trial/Trial.stories.tsx +5 -3
  253. package/src/trial/Trial.test.tsx +31 -4
  254. package/src/trial/Trial.trialId.test.tsx +23 -1
  255. package/src/trial/Trial.tsx +139 -52
  256. package/src/trial/TrialBody.tsx +17 -141
  257. package/src/trial/TrialChrome.tsx +24 -7
  258. package/src/trial/trialOps.test.ts +101 -1
  259. package/src/trial/trialOps.ts +67 -7
  260. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  261. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  262. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  263. package/dist/chunk-2P6PP5N4.js +0 -553
  264. package/dist/chunk-2P6PP5N4.js.map +0 -1
  265. package/dist/chunk-64ZCN3DA.js.map +0 -1
  266. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  267. package/dist/chunk-E44SU6XS.js +0 -50
  268. package/dist/chunk-E44SU6XS.js.map +0 -1
  269. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  270. package/dist/chunk-I6JCVE24.js.map +0 -1
  271. package/dist/chunk-MOM3GOVY.js.map +0 -1
  272. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  273. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  274. package/dist/chunk-U3IYHIAE.js +0 -343
  275. package/dist/chunk-U3IYHIAE.js.map +0 -1
  276. package/dist/chunk-W3ECWC2K.js +0 -7335
  277. package/dist/chunk-W3ECWC2K.js.map +0 -1
  278. package/src/theme/interstellar.tokens.json +0 -61
@@ -42,6 +42,11 @@ interface SurfaceFrame {
42
42
  width: number;
43
43
  height: number;
44
44
  };
45
+ /** The tile geometry changed this frame — a tile moved, resized, appeared or
46
+ * went away, or the dpr did. Every tile is dirty on such a frame, but the
47
+ * pixels a tile vacated are in no tile's scissor now, so the owner clears
48
+ * the whole buffer. `<Lab>` does; a host owning its own surface must too. */
49
+ retiled: boolean;
45
50
  }
46
51
  /** The invalidators and the two ref callbacks that publish geometry. */
47
52
  interface SurfaceHandle {
@@ -53,6 +58,12 @@ interface SurfaceHandle {
53
58
  * moved something a ResizeObserver cannot see. */
54
59
  invalidateRects: () => void;
55
60
  registerTile: (id: string, el: HTMLElement | null) => void;
61
+ /** Register a tenant's whole-buffer clear. Every one registered runs, before
62
+ * any painter, on a frame where the tile geometry changed — the pixels a
63
+ * tile vacated are in no scissor now, and only the context that drew them
64
+ * can erase them. Backend-agnostic by necessity: labkit owns the canvas,
65
+ * never the context. */
66
+ registerClear: (id: string, clear: SurfaceClear) => () => void;
56
67
  /** Subscribe a tile to the frames it is dirty on. A tile paints on its own
57
68
  * loop, so this is how the surface wakes one: a resize of the shared buffer
58
69
  * clears every tile, not only the one that moved. */
@@ -65,6 +76,12 @@ interface SurfaceHandle {
65
76
  /** What a tile's painter is handed: where it sits on the surface now, and the
66
77
  * frame that dirtied it. */
67
78
  type TilePainter = (rect: Rect, frame: SurfaceFrame) => void;
79
+ /** Erase the whole shared buffer. Device pixels, because that is what a GL
80
+ * viewport takes. */
81
+ type SurfaceClear = (size: {
82
+ width: number;
83
+ height: number;
84
+ }, dpr: number) => void;
68
85
  interface UseTiledSurfaceOptions {
69
86
  onFrame: (frame: SurfaceFrame) => void;
70
87
  }
@@ -72,13 +89,33 @@ declare function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHa
72
89
 
73
90
  /** Null when no surface owner is above — a lab with no shared surface at all. */
74
91
  declare const SurfaceContext: react.Context<SurfaceHandle | null>;
75
- /** The buffer the surface's tiles paint into. Null until the owner's canvas is
76
- * mounted, and for an owner that keeps its surface unpainted. */
77
- declare const SurfaceCanvasContext: react.Context<HTMLCanvasElement | null>;
92
+ /**
93
+ * Which of the two stacked buffers a tenant paints into.
94
+ *
95
+ * `'over'` sits above the trial DOM: right for marks that annotate an
96
+ * instrument, which is what the surface was built for and so the default.
97
+ * `'under'` sits below it, which is what an opaque renderer needs — a tile that
98
+ * fills its pane hides anything the pane's own DOM draws, so a 3D viewport on
99
+ * the over-buffer has to paint its own chrome into the backend rather than
100
+ * putting a label or a button in the pane.
101
+ *
102
+ * Both exist for the lifetime of the surface and share one set of tile rects,
103
+ * so a lab can hold an annotated 2D trial and an opaque 3D one at once.
104
+ */
105
+ type SurfaceLayer = 'over' | 'under';
106
+ /** The two buffers a surface owner publishes. A member is null until the
107
+ * owner's canvas mounts, and for an owner that keeps its surface unpainted. */
108
+ interface SurfaceCanvases {
109
+ over: HTMLCanvasElement | null;
110
+ under: HTMLCanvasElement | null;
111
+ }
112
+ /** The buffers the surface's tiles paint into. */
113
+ declare const SurfaceCanvasContext: react.Context<SurfaceCanvases>;
78
114
 
79
- /** The surface's shared buffer, or null where nothing paints one. What a tile
80
- * hands to `paintInto`. */
81
- declare function useSurfaceCanvas(): HTMLCanvasElement | null;
115
+ /** One of the surface's two shared buffers, or null where nothing paints one.
116
+ * What a tile hands to `paintInto`. See {@link SurfaceLayer} for which to ask
117
+ * for; the default is the one over the trial DOM. */
118
+ declare function useSurfaceCanvas(layer?: SurfaceLayer): HTMLCanvasElement | null;
82
119
  /** The surface above, or null. Use this where a surface is genuinely optional. */
83
120
  declare function useSurfaceOptional(): SurfaceHandle | null;
84
121
  /** The surface above. Throws where a caller cannot work without one. */
@@ -105,4 +142,4 @@ declare function useTileId(id: string): string;
105
142
  declare function useSurfaceTile(id: string): (el: HTMLElement | null) => void;
106
143
 
107
144
  export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId, useTiledSurface };
108
- export type { Box, Rect, SurfaceFrame, SurfaceHandle, TilePainter, UseTiledSurfaceOptions };
145
+ export type { Box, Rect, SurfaceCanvases, SurfaceClear, SurfaceFrame, SurfaceHandle, SurfaceLayer, TilePainter, UseTiledSurfaceOptions };
@@ -1,4 +1,5 @@
1
- export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId, useTiledSurface } from '../chunk-FQMJUVHJ.js';
1
+ export { composeRects, rectsEqual, toDeviceRect, useTiledSurface } from '../chunk-IJJUK4FU.js';
2
+ export { SurfaceCanvasContext, SurfaceContext, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId } from '../chunk-WIG6XHJ7.js';
2
3
  import '../chunk-2YILQ7Y5.js';
3
4
  //# sourceMappingURL=index.js.map
4
5
  //# sourceMappingURL=index.js.map
@@ -1,3 +1,3 @@
1
- export { c3 as LayerStack } from '../../chunk-W3ECWC2K.js';
1
+ export { c4 as LayerStack } from '../../chunk-T3EC2DXD.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
@@ -1,10 +1,8 @@
1
- export { a0 as UndoCapability } from '../_dts/frac-z7ker2Vx.js';
1
+ export { at as UndoCapability } from '../_dts/frac-B4okC2lX.js';
2
2
  import 'react';
3
- import '../_dts/types-B9_zrHmb.js';
3
+ import '../_dts/types-DzoNY1pO.js';
4
4
  import '@weasel-js/core';
5
- import '../_dts/PrefsForm.d-DDHFANkc.js';
6
- import '../_dts/types-x92Kfeme.js';
7
- import '../_dts/types-BP2OCcpg.js';
5
+ import '../_dts/PrefsForm.d-DPy9I8It.js';
8
6
  import '../_dts/types-DJ79Tg5J.js';
9
7
 
10
8
  /** A subscriber to a named event. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.4.4",
3
+ "version": "1.5.1",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -42,6 +42,10 @@
42
42
  "types": "./dist/controls/index.d.ts",
43
43
  "import": "./dist/controls/index.js"
44
44
  },
45
+ "./config": {
46
+ "types": "./dist/config/index.d.ts",
47
+ "import": "./dist/config/index.js"
48
+ },
45
49
  "./canvas": {
46
50
  "types": "./dist/canvas/index.d.ts",
47
51
  "import": "./dist/canvas/index.js"
@@ -107,18 +111,21 @@
107
111
  "docs:build": "vitepress build docs",
108
112
  "docs:preview": "vitepress preview docs",
109
113
  "dev:schema": "LABKIT_EXAMPLE=schema-lab vite",
110
- "dev:annotate": "LABKIT_EXAMPLE=annotate-lab vite"
114
+ "dev:annotate": "LABKIT_EXAMPLE=annotate-lab vite",
115
+ "dev:3d": "LABKIT_EXAMPLE=3d-lab vite"
111
116
  },
112
117
  "peerDependencies": {
113
- "@weasel-js/core": "1.4.4",
118
+ "@weasel-js/core": "1.5.1",
119
+ "@weasel-js/theme": "1.5.1",
114
120
  "react": "^19.0.0",
115
121
  "react-dom": "^19.0.0"
116
122
  },
117
123
  "dependencies": {
118
- "@weasel-js/loupe": "1.4.4",
119
- "@weasel-js/svg": "1.4.4",
120
- "@weasel-js/theme": "1.4.4",
121
- "@weasel-js/ui": "1.4.4",
124
+ "@weasel-js/geom": "1.5.1",
125
+ "@weasel-js/kernel3d": "1.5.1",
126
+ "@weasel-js/loupe": "1.5.1",
127
+ "@weasel-js/svg": "1.5.1",
128
+ "@weasel-js/ui": "1.5.1",
122
129
  "earcut": "2.2.4",
123
130
  "polygon-clipping": "^0.15.7",
124
131
  "react-aria-components": "^1.5.0",
@@ -129,7 +136,7 @@
129
136
  "@biomejs/biome": "^2.4.12",
130
137
  "@rollup/plugin-alias": "^6.0.0",
131
138
  "@testing-library/jest-dom": "^6.6.0",
132
- "@weasel-js/modes": "1.4.4",
139
+ "@weasel-js/modes": "1.5.1",
133
140
  "less": "^4.2.0",
134
141
  "rollup-plugin-dts": "^6.4.1",
135
142
  "tsx": "^4.19.0",
@@ -6,8 +6,9 @@ import {
6
6
  type View,
7
7
  WeaselProvider,
8
8
  } from '@weasel-js/core';
9
- import { type CSSProperties, useEffect, useRef, useState } from 'react';
10
- import { createPortal } from 'react-dom';
9
+ import { type CSSProperties, useCallback, useContext, useEffect, useRef, useState } from 'react';
10
+ import { createPortal, flushSync } from 'react-dom';
11
+ import { CameraWheelContext } from '../canvas/CameraWheelContext';
11
12
  import type { Rect } from '../surface/rect';
12
13
  import { useSurfaceCanvas, useSurfaceOptional, useTileId } from '../surface/useSurfaceTile';
13
14
  import { createMarkDrawOne } from './drawOne';
@@ -34,7 +35,7 @@ export interface AnnotationOverlayProps {
34
35
  config: unknown;
35
36
  /** The trial's resolved tool slot. */
36
37
  activeToolId: string | null;
37
- /** The instrument's vocabulary, for a status's colour. */
38
+ /** The instrument's vocabulary, for a status's color. */
38
39
  meaning?: AnnotationMeaning;
39
40
  }
40
41
 
@@ -123,10 +124,57 @@ export function AnnotationOverlay({
123
124
  [surface, tileId],
124
125
  );
125
126
 
127
+ // Erases the whole shared buffer when the tiles move: a tile that moved took
128
+ // its scissor with it, and what it painted last frame stays where it was.
129
+ // Each renderer on the context re-applies its viewport and scissor per frame,
130
+ // so resetting them here is safe.
131
+ // Only once this pane has painted: a bare `getContext` on a buffer no
132
+ // renderer has opened creates the context without the stencil buffer marks
133
+ // need, and every later request gets that context back.
134
+ const painted = useRef(false);
135
+ const canvasRef = useRef(canvas);
136
+ if (canvasRef.current !== canvas) painted.current = false;
137
+ canvasRef.current = canvas;
138
+ const unsubscribeFrame = useRef<(() => void) | null>(null);
139
+ const attachSceneCanvas = useCallback((api: SceneCanvasApi | null) => {
140
+ sceneCanvas.current = api;
141
+ unsubscribeFrame.current?.();
142
+ unsubscribeFrame.current = api
143
+ ? api.subscribeFrame(() => {
144
+ painted.current = true;
145
+ })
146
+ : null;
147
+ }, []);
148
+ useEffect(() => {
149
+ if (!surface) return;
150
+ return surface.registerClear(tileId, (size, dpr) => {
151
+ if (!painted.current) return;
152
+ const gl = canvasRef.current?.getContext('webgl2');
153
+ if (!gl) return;
154
+ gl.disable(gl.SCISSOR_TEST);
155
+ gl.viewport(0, 0, Math.round(size.width * dpr), Math.round(size.height * dpr));
156
+ gl.clearColor(0, 0, 0, 0);
157
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
158
+ });
159
+ }, [surface, tileId]);
160
+
161
+ // The box is portalled out of whatever the picture sits in, so a wheel over
162
+ // a mark would never reach the trial's camera without being handed to it.
163
+ const wheelSlot = useContext(CameraWheelContext);
164
+ useEffect(() => {
165
+ if (!input || !wheelSlot) return;
166
+ const wheel = (e: WheelEvent): void => wheelSlot.current?.(e);
167
+ input.addEventListener('wheel', wheel, { passive: false });
168
+ return () => input.removeEventListener('wheel', wheel);
169
+ }, [input, wheelSlot]);
170
+
126
171
  useEffect(
127
172
  () =>
128
173
  surface?.registerPainter(tileId, (next) => {
129
- setRect((prev) => (sameRect(prev, next) ? prev : next));
174
+ // Committed before the redraw is asked for: the renderer reads its
175
+ // position from the last render, so a redraw landing first repaints the
176
+ // tile where it was — after this frame's clear, which leaves a ghost.
177
+ flushSync(() => setRect((prev) => (sameRect(prev, next) ? prev : next)));
130
178
  // The shared buffer was cleared if it resized this frame; this pane's
131
179
  // own loop has no way to know that.
132
180
  sceneCanvas.current?.requestRedraw();
@@ -178,6 +226,9 @@ export function AnnotationOverlay({
178
226
  ref={setInput}
179
227
  className="lk-annotate__input"
180
228
  data-annotation-target={id}
229
+ // Pass-through while the rail holds a tool that makes no marks, so the
230
+ // instrument under the overlay stays clickable.
231
+ data-idle={annotationToolInfo(activeToolId) ? undefined : ''}
181
232
  role="application"
182
233
  aria-label={`Annotations on ${id}`}
183
234
  // biome-ignore lint/a11y/noNoninteractiveTabindex: role="application" IS the interactive case — a region with its own keymap. The rule's allowed-role list stops at the native widget roles and does not include it, and the gesture dispatcher listens on this element, so without focus no tool's keyboard binding ever fires.
@@ -190,7 +241,7 @@ export function AnnotationOverlay({
190
241
  <WeaselProvider isolate>
191
242
  <ToolBridge toolId={annotationToolInfo(activeToolId)?.weaselTool ?? 'select'} />
192
243
  <SceneCanvas<AnnotationData, 'marks', WorldRect>
193
- ref={sceneCanvas}
244
+ ref={attachSceneCanvas}
194
245
  scene={scene}
195
246
  width={rect.w}
196
247
  height={rect.h}
@@ -8,6 +8,10 @@
8
8
  width: var(--lk-anno-w);
9
9
  height: var(--lk-anno-h);
10
10
  z-index: 2;
11
+
12
+ &[data-idle] {
13
+ pointer-events: none;
14
+ }
11
15
  }
12
16
 
13
17
  // The sidebar list of marks. One row per mark: what it is, where it is, what
@@ -8,11 +8,12 @@
8
8
  * marks themselves are proved by `paint.test.ts` (pure) and by a screenshot.
9
9
  */
10
10
  import { act, render } from '@testing-library/react';
11
- import { StrictMode, useRef } from 'react';
11
+ import { StrictMode, useMemo, useRef } from 'react';
12
12
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
13
+ import { CameraWheelContext, type CameraWheelSlot } from '../canvas/CameraWheelContext';
13
14
  import { TrialIdProvider } from '../state/context';
14
15
  import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
15
- import type { SurfaceHandle } from '../surface/useTiledSurface';
16
+ import type { SurfaceClear, SurfaceHandle } from '../surface/useTiledSurface';
16
17
  import { useTiledSurface } from '../surface/useTiledSurface';
17
18
  import { AnnotationTargets } from './AnnotationTargets';
18
19
  import { createAnnotationStore } from './store';
@@ -38,10 +39,33 @@ beforeAll(() => {
38
39
  ) as unknown as HTMLCanvasElement['getContext'];
39
40
  });
40
41
 
41
- function Harness({ toolId = 'rect' }: { toolId?: string }) {
42
+ function Harness({
43
+ toolId = 'rect',
44
+ onRegisterClear,
45
+ canvas = null,
46
+ }: {
47
+ toolId?: string;
48
+ /** Told each tile a clear is registered for, and handed the clear. */
49
+ onRegisterClear?: (id: string, clear: SurfaceClear) => void;
50
+ /** The shared buffer the overlays paint into. */
51
+ canvas?: HTMLCanvasElement | null;
52
+ }) {
42
53
  const a = useRef<HTMLDivElement | null>(null);
43
54
  const b = useRef<HTMLDivElement | null>(null);
44
- const surface = useTiledSurface({ onFrame: () => {} });
55
+ const tiled = useTiledSurface({ onFrame: () => {} });
56
+ const surface = useMemo<SurfaceHandle>(
57
+ () =>
58
+ onRegisterClear
59
+ ? {
60
+ ...tiled,
61
+ registerClear: (id, clear) => {
62
+ onRegisterClear(id, clear);
63
+ return tiled.registerClear(id, clear);
64
+ },
65
+ }
66
+ : tiled,
67
+ [tiled, onRegisterClear],
68
+ );
45
69
  const annotations = useRef(createAnnotationStore({ targets: () => [] })).current;
46
70
 
47
71
  const capability: AnnotationsCapability = {
@@ -53,7 +77,7 @@ function Harness({ toolId = 'rect' }: { toolId?: string }) {
53
77
 
54
78
  return (
55
79
  <SurfaceContext.Provider value={surface}>
56
- <SurfaceCanvasContext.Provider value={null}>
80
+ <SurfaceCanvasContext.Provider value={{ over: canvas, under: null }}>
57
81
  <div
58
82
  data-testid="stage"
59
83
  ref={(el) => {
@@ -122,6 +146,26 @@ describe('<AnnotationTargets>', () => {
122
146
  expect(boxes[1]?.style.getPropertyValue('--lk-anno-h')).toBe('100px');
123
147
  });
124
148
 
149
+ it('takes input while a mark tool is held, and passes it through otherwise', () => {
150
+ const drawing = render(<Harness toolId="rect" />, { wrapper: StrictMode });
151
+ act(() => {
152
+ vi.advanceTimersByTime(64);
153
+ });
154
+ expect(drawing.container.querySelector('.lk-annotate__input')?.hasAttribute('data-idle')).toBe(
155
+ false,
156
+ );
157
+ drawing.unmount();
158
+
159
+ // `pointer` is in the rail but not in the tool map: the instrument under the overlay stays clickable.
160
+ const idle = render(<Harness toolId="pointer" />, { wrapper: StrictMode });
161
+ act(() => {
162
+ vi.advanceTimersByTime(64);
163
+ });
164
+ expect(idle.container.querySelector('.lk-annotate__input')?.hasAttribute('data-idle')).toBe(
165
+ true,
166
+ );
167
+ });
168
+
125
169
  it('lands the boxes in the surface container, not among the instrument DOM', () => {
126
170
  const { container } = render(<Harness />, { wrapper: StrictMode });
127
171
  act(() => {
@@ -133,6 +177,61 @@ describe('<AnnotationTargets>', () => {
133
177
  }
134
178
  });
135
179
 
180
+ it('hands a wheel over a target to the trial camera', () => {
181
+ // The input box is portalled into the surface container, so the wheel
182
+ // would otherwise bubble past the stage the picture sits on.
183
+ const wheel = vi.fn();
184
+ const slot: CameraWheelSlot = { current: wheel };
185
+ const { container } = render(
186
+ <CameraWheelContext.Provider value={slot}>
187
+ <Harness />
188
+ </CameraWheelContext.Provider>,
189
+ { wrapper: StrictMode },
190
+ );
191
+ act(() => {
192
+ vi.advanceTimersByTime(64);
193
+ });
194
+ const box = container.querySelector('.lk-annotate__input');
195
+ box?.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true }));
196
+ expect(wheel).toHaveBeenCalledTimes(1);
197
+ });
198
+
199
+ it('registers a clear for each tile, so a moved mark leaves nothing behind', () => {
200
+ // A proxy: jsdom has no WebGL2, so the clear itself cannot run here. Without
201
+ // one registered, a tile that moves leaves its last frame's marks painted
202
+ // where it used to be — which the surface only erases through a clear.
203
+ const registered = vi.fn();
204
+ render(<Harness onRegisterClear={registered} />, { wrapper: StrictMode });
205
+ act(() => {
206
+ vi.advanceTimersByTime(64);
207
+ });
208
+ expect(new Set(registered.mock.calls.map((call) => call[0])).size).toBe(2);
209
+ });
210
+
211
+ it('never opens the shared context itself when it clears', () => {
212
+ // A bare `getContext('webgl2')` on a buffer nobody has opened yet creates
213
+ // the context with default attributes, and the renderer's later request for
214
+ // a stencil buffer then gets that one back: every mark paints nothing, with
215
+ // no error anywhere. jsdom opens no context, so the call is the proxy.
216
+ const clears: SurfaceClear[] = [];
217
+ const canvas = document.createElement('canvas');
218
+ const { unmount } = render(
219
+ <Harness canvas={canvas} onRegisterClear={(_id, clear) => clears.push(clear)} />,
220
+ { wrapper: StrictMode },
221
+ );
222
+ act(() => {
223
+ vi.advanceTimersByTime(64);
224
+ });
225
+ // The prototype is already a mock file-wide, so the spy arrives holding
226
+ // the renderer's own attempts to open it.
227
+ const open = vi.spyOn(canvas, 'getContext');
228
+ open.mockClear();
229
+ for (const clear of clears) clear({ width: 800, height: 600 }, 1);
230
+ expect(open).not.toHaveBeenCalled();
231
+ // Before the frame stubs come off: the canvas's own loop cancels through them.
232
+ unmount();
233
+ });
234
+
136
235
  it('takes its tiles back out on unmount', () => {
137
236
  const { container, unmount } = render(<Harness />, { wrapper: StrictMode });
138
237
  act(() => {
@@ -206,7 +305,7 @@ describe('two trials of one instrument', () => {
206
305
  handle = surface;
207
306
  return (
208
307
  <SurfaceContext.Provider value={surface}>
209
- <SurfaceCanvasContext.Provider value={null}>
308
+ <SurfaceCanvasContext.Provider value={{ over: null, under: null }}>
210
309
  <div
211
310
  ref={(el) => {
212
311
  if (!el) return;
@@ -23,7 +23,7 @@ const strokeOf = (cmds: ReturnType<ReturnType<typeof createMarkDrawOne>>) => {
23
23
  };
24
24
 
25
25
  describe('the draw callback marks share between the screen and an export', () => {
26
- it("paints a mark in its status's colour", () => {
26
+ it("paints a mark in its status's color", () => {
27
27
  const drawOne = createMarkDrawOne({
28
28
  content: CONTENT,
29
29
  config: {},
@@ -12,7 +12,7 @@ export interface MarkDrawOptions {
12
12
  positionDependsOn?: readonly string[];
13
13
  /** The trial's config at the time of the draw. */
14
14
  config: unknown;
15
- /** The instrument's vocabulary, for a status's colour. */
15
+ /** The instrument's vocabulary, for a status's color. */
16
16
  meaning?: AnnotationMeaning;
17
17
  }
18
18
 
@@ -20,7 +20,7 @@ export interface MarkDrawOptions {
20
20
  * How one mark is drawn — shared by the pane on screen and by an export.
21
21
  *
22
22
  * One function rather than two call sites building their own, because an
23
- * export that resolves a status colour or a stale dash differently from the
23
+ * export that resolves a status color or a stale dash differently from the
24
24
  * screen produces a picture nobody was looking at.
25
25
  */
26
26
  export function createMarkDrawOne(
@@ -102,7 +102,7 @@ describe('markCommands', () => {
102
102
  });
103
103
 
104
104
  describe('markCommands styling', () => {
105
- it('takes a status colour over the default', () => {
105
+ it('takes a status color over the default', () => {
106
106
  const [cmd] = markCommands(mark('rect'), CONTENT, { color: '#30a46c' });
107
107
  expect((cmd as { stroke?: { paint?: { color?: string } } }).stroke?.paint?.color).toBe(
108
108
  '#30a46c',
@@ -117,7 +117,7 @@ describe('markCommands styling', () => {
117
117
  expect((stale as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
118
118
  });
119
119
 
120
- it("colours a text mark's glyphs too, not only the outlines", () => {
120
+ it("colors a text mark's glyphs too, not only the outlines", () => {
121
121
  const [cmd] = markCommands(mark('text', { title: 'x' }), CONTENT, { color: '#30a46c' });
122
122
  const runs = (cmd as { runs: { fill?: { color?: string } }[] }).runs;
123
123
  expect(runs[0]?.fill?.color).toBe('#30a46c');
@@ -27,7 +27,7 @@ export interface PaintableMark {
27
27
  /** How a mark is drawn, as opposed to where. Resolved by the overlay from the
28
28
  * instrument's vocabulary and the mark's own staleness. */
29
29
  export interface MarkStyle {
30
- /** The status's colour, or the default. */
30
+ /** The status's color, or the default. */
31
31
  color?: string;
32
32
  /** A mark whose stored position no longer describes the picture. Drawn
33
33
  * dashed rather than hidden: it still describes *something*, and dropping
@@ -0,0 +1,6 @@
1
+ import { createContext } from 'react';
2
+
3
+ /** Marks an instrument keeps in its own `AnnotationStorage`, already loaded by
4
+ * `<Lab>` for the trials present when it opened, keyed by trial id. A trial
5
+ * missing from it loads its own. */
6
+ export const AnnotationPreloadContext = createContext<ReadonlyMap<string, unknown> | null>(null);
@@ -244,4 +244,22 @@ describe('the annotation store', () => {
244
244
  expect(revived.get(id)).toEqual(store.get(id));
245
245
  expect(revived.query()).toHaveLength(1);
246
246
  });
247
+
248
+ it('reads a meaning passed as a thunk at each capture', async () => {
249
+ let color = '#111111';
250
+ const store = annotationsFromJSON(null, () => TARGETS, {
251
+ meaning: () => ({ statuses: [{ id: 'open', label: 'Open', color }] }),
252
+ });
253
+ store.add(RING, { angle: 'iso', shading: 'outline' });
254
+ color = '#222222';
255
+ const { blob } = await store.capture('naive', { format: 'svg' });
256
+ // jsdom's Blob has no text().
257
+ const svg = await new Promise<string>((resolve) => {
258
+ const reader = new FileReader();
259
+ reader.onload = () => resolve(String(reader.result));
260
+ reader.readAsText(blob);
261
+ });
262
+ expect(svg).not.toContain('#111111');
263
+ expect(svg).toContain('#222222');
264
+ });
247
265
  });
@@ -43,9 +43,10 @@ export interface AnnotationStoreOptions {
43
43
  targets: () => readonly AnnotationTargetInfo[];
44
44
  /** Serialized scenes from a previous `toJSON`, keyed by target. */
45
45
  restore?: Readonly<Record<string, unknown>>;
46
- /** The instrument's vocabulary, so an export draws a mark in the colour its
47
- * status gives it. */
48
- meaning?: AnnotationMeaning;
46
+ /** The instrument's vocabulary, so an export draws a mark in the color its
47
+ * status gives it. A thunk is read at each capture, for a vocabulary that
48
+ * changes under a store built once. */
49
+ meaning?: AnnotationMeaning | (() => AnnotationMeaning | undefined);
49
50
  /** The trial's live config. A getter for the same reason `targets` is: the
50
51
  * store is built once and the config changes under it. */
51
52
  config?: () => unknown;
@@ -314,7 +315,7 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
314
315
  content: info.content,
315
316
  positionDependsOn: info.positionDependsOn,
316
317
  config: opts.config?.(),
317
- meaning: opts.meaning,
318
+ meaning: typeof opts.meaning === 'function' ? opts.meaning() : opts.meaning,
318
319
  },
319
320
  base: info.base,
320
321
  onWarn: (message) => console.warn(`[labkit] capture: ${message}`),
@@ -39,7 +39,7 @@ describe('a mark as vector', () => {
39
39
  expect(node.stroke?.cap).toBe(cmd.stroke?.cap);
40
40
  expect(node.stroke?.join).toBe(cmd.stroke?.join);
41
41
  // An arrow's head arrives filled and unstroked; the shaft is the other
42
- // way round. Whichever paint the command carries is the mark colour.
42
+ // way round. Whichever paint the command carries is the mark color.
43
43
  const paint = node.stroke?.paint ?? node.fill;
44
44
  expect(paint).toEqual({ kind: 'solid', color: '#e5484d' });
45
45
  }
@@ -65,7 +65,7 @@ describe('a mark as vector', () => {
65
65
  );
66
66
  });
67
67
 
68
- it('emits text as text, at the pose, in the mark colour', () => {
68
+ it('emits text as text, at the pose, in the mark color', () => {
69
69
  const m = mark('text', { title: 'missing edge' });
70
70
  const [node] = markSvgNodes(m, CONTENT);
71
71
  expect(node).toMatchObject({ kind: 'text', text: 'missing edge', x: 10, y: 12 });
@@ -27,6 +27,7 @@ function toSvgStroke(stroke: Stroke): SvgStroke {
27
27
  ...(stroke.join ? { join: stroke.join } : {}),
28
28
  ...(stroke.dash ? { dash: [...stroke.dash] } : {}),
29
29
  ...(stroke.miterLimit != null ? { miterLimit: stroke.miterLimit } : {}),
30
+ ...(stroke.align ? { align: stroke.align } : {}),
30
31
  };
31
32
  }
32
33
 
@@ -6,8 +6,12 @@ import type { AnnotationKind } from './types';
6
6
  const KINDS: AnnotationKind[] = ['stroke', 'line', 'arrow', 'rect', 'ellipse', 'text'];
7
7
 
8
8
  describe('the annotation tool table', () => {
9
- it('offers one tool per mark kind, plus select', () => {
10
- expect(ANNOTATION_TOOLS.map((t) => t.id)).toEqual(['select', ...KINDS]);
9
+ it('offers one tool per mark kind, plus pointer and select', () => {
10
+ expect(ANNOTATION_TOOLS.map((t) => t.id)).toEqual(['pointer', 'select', ...KINDS]);
11
+ });
12
+
13
+ it('gives pointer no entry at all, which is what leaves the overlay idle', () => {
14
+ expect(annotationToolInfo('pointer')).toBeUndefined();
11
15
  });
12
16
 
13
17
  it('only names weasel tools SceneCanvas will mount', () => {
@@ -15,9 +19,9 @@ describe('the annotation tool table', () => {
15
19
  // being built in should fail here rather than in a silent no-op palette.
16
20
  const mountable = new Set<string>([...KIT_SHAPE_KINDS, 'select']);
17
21
  for (const t of ANNOTATION_TOOLS) {
18
- expect(mountable, `${t.id} maps to a tool the kit does not mount`).toContain(
19
- annotationToolInfo(t.id)?.weaselTool,
20
- );
22
+ const info = annotationToolInfo(t.id);
23
+ if (!info) continue;
24
+ expect(mountable, `${t.id} maps to a tool the kit does not mount`).toContain(info.weaselTool);
21
25
  }
22
26
  });
23
27
 
@@ -2,6 +2,7 @@ import type { BuiltinToolId } from '@weasel-js/core';
2
2
  import {
3
3
  ArrowIcon,
4
4
  EllipseIcon,
5
+ HandIcon,
5
6
  LineIcon,
6
7
  PencilIcon,
7
8
  RectIcon,
@@ -30,10 +31,12 @@ const TOOLS: Record<string, AnnotationToolInfo> = {
30
31
  text: { weaselTool: 'text', kind: 'text' },
31
32
  };
32
33
 
33
- /** The palette an instrument gets for declaring `annotations`. Ids share the
34
- * trial's contribution namespace with `instrument.tools`, and one tool slot
35
- * holds whichever is active. */
34
+ /** The tools a lab's rail gets when any instrument declares `annotations`.
35
+ * Ids share the lab's contribution namespace with its own `tools`, and the
36
+ * lab's tool slot holds whichever is active. */
36
37
  export const ANNOTATION_TOOLS: readonly TrialTool[] = [
38
+ // Not in `TOOLS`: an id the map does not carry is what leaves the overlay idle.
39
+ { id: 'pointer', label: 'Interact', icon: HandIcon, group: 'annotate' },
37
40
  { id: 'select', label: 'Select', icon: SelectIcon, group: 'annotate' },
38
41
  { id: 'stroke', label: 'Freehand', icon: PencilIcon, group: 'annotate' },
39
42
  { id: 'line', label: 'Line', icon: LineIcon, group: 'annotate' },