@weasel-js/labkit 1.4.4 → 1.5.0

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 (219) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-8R6UmlvN.d.ts} +253 -31
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-retnAut7.d.ts} +18 -10
  7. package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
  8. package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
  9. package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
  10. package/dist/canvas/index.d.ts +17 -128
  11. package/dist/canvas/index.js +4 -2
  12. package/dist/chrome/index.d.ts +4 -92
  13. package/dist/chrome/index.js +9 -5
  14. package/dist/chunk-67SJLMC7.js +704 -0
  15. package/dist/chunk-67SJLMC7.js.map +1 -0
  16. package/dist/chunk-6ZDGOZQV.js +310 -0
  17. package/dist/chunk-6ZDGOZQV.js.map +1 -0
  18. package/dist/{chunk-MOM3GOVY.js → chunk-EKIICY6X.js} +111 -25
  19. package/dist/chunk-EKIICY6X.js.map +1 -0
  20. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  21. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  22. package/dist/{chunk-SMHP6XZ4.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-BKFVHKJH.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-BKFVHKJH.js.map → chunk-MUKOW3TC.js.map} +1 -1
  26. package/dist/chunk-NDRYLVOW.js +15 -0
  27. package/dist/chunk-NDRYLVOW.js.map +1 -0
  28. package/dist/{chunk-64ZCN3DA.js → chunk-NS64DXMN.js} +226 -159
  29. package/dist/chunk-NS64DXMN.js.map +1 -0
  30. package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
  31. package/dist/chunk-PGETSEDK.js.map +1 -0
  32. package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
  33. package/dist/chunk-QLSV2N3G.js.map +1 -0
  34. package/dist/chunk-RWNDDKOH.js +682 -0
  35. package/dist/chunk-RWNDDKOH.js.map +1 -0
  36. package/dist/chunk-S2SHZRD7.js +7681 -0
  37. package/dist/chunk-S2SHZRD7.js.map +1 -0
  38. package/dist/{chunk-U3IYHIAE.js → chunk-TQBBYTAF.js} +19 -82
  39. package/dist/chunk-TQBBYTAF.js.map +1 -0
  40. package/dist/chunk-UU3NJO6N.js +72 -0
  41. package/dist/chunk-UU3NJO6N.js.map +1 -0
  42. package/dist/chunk-WIG6XHJ7.js +35 -0
  43. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  44. package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
  45. package/dist/chunk-ZPH5WTWF.js.map +1 -0
  46. package/dist/config/index.d.ts +232 -0
  47. package/dist/config/index.js +6 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/controls/index.d.ts +2 -3
  50. package/dist/controls/index.js +3 -2
  51. package/dist/dragdrop/index.d.ts +2 -4
  52. package/dist/index.d.ts +110 -381
  53. package/dist/index.js +691 -817
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +3 -5
  56. package/dist/layers/index.js +2 -2
  57. package/dist/loupe/index.d.ts +6 -8
  58. package/dist/loupe/index.js +2 -2
  59. package/dist/passthrough/weasel-ui.d.ts +164 -122
  60. package/dist/passthrough/weasel-ui.js +1 -1
  61. package/dist/primitives/index.d.ts +53 -6
  62. package/dist/primitives/index.js +6 -4
  63. package/dist/state/index.d.ts +12 -7
  64. package/dist/state/index.js +16 -13
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/styles.css +84 -50
  67. package/dist/surface/index.d.ts +44 -7
  68. package/dist/surface/index.js +2 -1
  69. package/dist/ui/layers/index.js +1 -1
  70. package/dist/undo/index.d.ts +2 -4
  71. package/package.json +15 -8
  72. package/src/annotations/AnnotationOverlay.tsx +53 -5
  73. package/src/annotations/Annotations.overlay.test.tsx +85 -6
  74. package/src/annotations/drawOne.test.ts +1 -1
  75. package/src/annotations/drawOne.ts +2 -2
  76. package/src/annotations/paint.test.ts +2 -2
  77. package/src/annotations/paint.ts +1 -1
  78. package/src/annotations/preload.ts +6 -0
  79. package/src/annotations/store.test.ts +18 -0
  80. package/src/annotations/store.ts +5 -4
  81. package/src/annotations/svgNodes.test.ts +2 -2
  82. package/src/annotations/toolMap.ts +3 -3
  83. package/src/annotations/types.ts +6 -3
  84. package/src/canvas/CameraWheelContext.ts +12 -0
  85. package/src/canvas/CanvasStack.test.tsx +25 -0
  86. package/src/canvas/CanvasStack.tsx +31 -4
  87. package/src/canvas/Stage.less +18 -0
  88. package/src/canvas/Stage.test.tsx +186 -0
  89. package/src/canvas/Stage.tsx +134 -0
  90. package/src/canvas/camera.test.ts +17 -2
  91. package/src/canvas/camera.ts +9 -9
  92. package/src/canvas/index.ts +4 -0
  93. package/src/canvas/usePanZoom.ts +10 -5
  94. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  95. package/src/chrome/LabChrome.tsx +114 -0
  96. package/src/chrome/builtins.tsx +20 -31
  97. package/src/chrome/index.ts +15 -0
  98. package/src/chrome/labRegions.test.tsx +208 -0
  99. package/src/chrome/labTypes.ts +50 -0
  100. package/src/chrome/merge.ts +7 -9
  101. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  102. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  103. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  104. package/src/chrome/regions/StatusRegion.tsx +15 -8
  105. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  106. package/src/chrome/types.ts +47 -20
  107. package/src/config/builder.test.ts +4 -0
  108. package/src/config/builder.ts +16 -10
  109. package/src/config/declarationEmit.fixture.ts +11 -0
  110. package/src/config/declarationEmit.test.ts +51 -0
  111. package/src/config/entry.test.ts +46 -0
  112. package/src/config/index.ts +13 -1
  113. package/src/config/nodeClasses.test.ts +18 -0
  114. package/src/config/types.ts +3 -1
  115. package/src/controls/ControlPanel.test.tsx +16 -0
  116. package/src/controls/ControlPanel.tsx +5 -2
  117. package/src/fake-indexeddb-auto.d.ts +5 -0
  118. package/src/index.test.ts +7 -0
  119. package/src/index.ts +38 -5
  120. package/src/instrument/serializers.test.ts +22 -0
  121. package/src/instrument/serializers.ts +11 -0
  122. package/src/instrument/types.ts +25 -1
  123. package/src/lab/Lab.less +4 -13
  124. package/src/lab/Lab.persist.test.tsx +147 -0
  125. package/src/lab/Lab.stories.tsx +0 -2
  126. package/src/lab/Lab.surface.test.tsx +66 -3
  127. package/src/lab/Lab.test.tsx +91 -17
  128. package/src/lab/Lab.tsx +337 -124
  129. package/src/lab/LabFit.stories.less +18 -0
  130. package/src/lab/LabFit.stories.tsx +219 -0
  131. package/src/lab/LabFullChrome.stories.tsx +0 -3
  132. package/src/lab/LabHeader.test.tsx +40 -9
  133. package/src/lab/LabHeader.tsx +7 -13
  134. package/src/lab/LabPalette.tsx +13 -26
  135. package/src/lab/LabShell.less +49 -12
  136. package/src/lab/LabSwitcher.less +8 -1
  137. package/src/lab/Workspace.surface.test.tsx +2 -1
  138. package/src/lab/fitCheck.test.ts +57 -0
  139. package/src/lab/fitCheck.ts +125 -0
  140. package/src/loupe/AGENTS.md +1 -1
  141. package/src/loupe/Loupe.less +1 -1
  142. package/src/loupe/canvasLens.ts +1 -1
  143. package/src/loupe/types.ts +1 -1
  144. package/src/loupe/useLoupe.test.tsx +1 -1
  145. package/src/loupe/useLoupe.ts +1 -1
  146. package/src/passthrough/weasel-ui.ts +9 -0
  147. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  148. package/src/primitives/FloatingPanel.test.tsx +47 -16
  149. package/src/primitives/FloatingPanel.tsx +11 -25
  150. package/src/primitives/Split.less +14 -0
  151. package/src/primitives/Split.test.tsx +108 -0
  152. package/src/primitives/Split.tsx +159 -0
  153. package/src/primitives/ZoomControl.tsx +5 -2
  154. package/src/primitives/index.ts +2 -0
  155. package/src/state/Persistence.stories.tsx +75 -0
  156. package/src/state/Persistence.tsx +33 -0
  157. package/src/state/SingletonExperiment.test.tsx +47 -53
  158. package/src/state/SingletonExperiment.tsx +24 -13
  159. package/src/state/adapterContract.ts +116 -0
  160. package/src/state/adapters.test.ts +113 -61
  161. package/src/state/adapters.ts +328 -69
  162. package/src/state/document.test.ts +71 -96
  163. package/src/state/document.ts +66 -52
  164. package/src/state/helpers.test.ts +1 -1
  165. package/src/state/index.ts +8 -0
  166. package/src/state/labRecords.test.ts +105 -0
  167. package/src/state/labRecords.ts +160 -0
  168. package/src/state/openLabStore.test.ts +658 -0
  169. package/src/state/openLabStore.ts +403 -0
  170. package/src/state/records.test.ts +234 -0
  171. package/src/state/records.ts +219 -0
  172. package/src/state/store.test.ts +104 -635
  173. package/src/state/store.ts +116 -157
  174. package/src/state/toolSlot.test.ts +1 -1
  175. package/src/state/types.ts +39 -15
  176. package/src/state/useOpenOnce.ts +68 -0
  177. package/src/state/usePersistedState.test.tsx +125 -0
  178. package/src/state/usePersistedState.ts +66 -0
  179. package/src/state/useTrialState.test.tsx +2 -3
  180. package/src/state/view.test.ts +49 -15
  181. package/src/state/view.ts +18 -1
  182. package/src/styles.less +2 -0
  183. package/src/surface/AGENTS.md +39 -7
  184. package/src/surface/SurfaceContext.ts +26 -3
  185. package/src/surface/index.ts +2 -0
  186. package/src/surface/useSurfaceTile.test.tsx +2 -1
  187. package/src/surface/useSurfaceTile.ts +6 -5
  188. package/src/surface/useTiledSurface.test.tsx +175 -0
  189. package/src/surface/useTiledSurface.ts +54 -5
  190. package/src/theme/Interstellar.stories.tsx +7 -7
  191. package/src/theme/interstellar.test.ts +1 -1
  192. package/src/theme/interstellar.tokens.json +2 -2
  193. package/src/tools/labTool.ts +11 -0
  194. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  195. package/src/trial/Trial.annotations.test.tsx +47 -4
  196. package/src/trial/Trial.job.test.tsx +3 -3
  197. package/src/trial/Trial.less +1 -26
  198. package/src/trial/Trial.stories.tsx +1 -2
  199. package/src/trial/Trial.test.tsx +28 -4
  200. package/src/trial/Trial.trialId.test.tsx +23 -1
  201. package/src/trial/Trial.tsx +118 -38
  202. package/src/trial/TrialBody.tsx +17 -141
  203. package/src/trial/TrialChrome.tsx +7 -4
  204. package/src/trial/trialOps.ts +5 -3
  205. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  206. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  207. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  208. package/dist/chunk-2P6PP5N4.js +0 -553
  209. package/dist/chunk-2P6PP5N4.js.map +0 -1
  210. package/dist/chunk-64ZCN3DA.js.map +0 -1
  211. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  212. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  213. package/dist/chunk-I6JCVE24.js.map +0 -1
  214. package/dist/chunk-MOM3GOVY.js.map +0 -1
  215. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  216. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  217. package/dist/chunk-U3IYHIAE.js.map +0 -1
  218. package/dist/chunk-W3ECWC2K.js +0 -7335
  219. package/dist/chunk-W3ECWC2K.js.map +0 -1
@@ -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-QLSV2N3G.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 { c3 as LayerStack } from '../../chunk-S2SHZRD7.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 { as as UndoCapability } from '../_dts/frac-8R6UmlvN.js';
2
2
  import 'react';
3
- import '../_dts/types-B9_zrHmb.js';
3
+ import '../_dts/types-Ca2LCnPq.js';
4
4
  import '@weasel-js/core';
5
5
  import '../_dts/PrefsForm.d-DDHFANkc.js';
6
- import '../_dts/types-x92Kfeme.js';
7
- import '../_dts/types-BP2OCcpg.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.0",
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.0",
114
119
  "react": "^19.0.0",
115
120
  "react-dom": "^19.0.0"
116
121
  },
117
122
  "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",
123
+ "@weasel-js/geom": "1.5.0",
124
+ "@weasel-js/kernel3d": "1.5.0",
125
+ "@weasel-js/loupe": "1.5.0",
126
+ "@weasel-js/svg": "1.5.0",
127
+ "@weasel-js/theme": "1.5.0",
128
+ "@weasel-js/ui": "1.5.0",
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.0",
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();
@@ -190,7 +238,7 @@ export function AnnotationOverlay({
190
238
  <WeaselProvider isolate>
191
239
  <ToolBridge toolId={annotationToolInfo(activeToolId)?.weaselTool ?? 'select'} />
192
240
  <SceneCanvas<AnnotationData, 'marks', WorldRect>
193
- ref={sceneCanvas}
241
+ ref={attachSceneCanvas}
194
242
  scene={scene}
195
243
  width={rect.w}
196
244
  height={rect.h}
@@ -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) => {
@@ -133,6 +157,61 @@ describe('<AnnotationTargets>', () => {
133
157
  }
134
158
  });
135
159
 
160
+ it('hands a wheel over a target to the trial camera', () => {
161
+ // The input box is portalled into the surface container, so the wheel
162
+ // would otherwise bubble past the stage the picture sits on.
163
+ const wheel = vi.fn();
164
+ const slot: CameraWheelSlot = { current: wheel };
165
+ const { container } = render(
166
+ <CameraWheelContext.Provider value={slot}>
167
+ <Harness />
168
+ </CameraWheelContext.Provider>,
169
+ { wrapper: StrictMode },
170
+ );
171
+ act(() => {
172
+ vi.advanceTimersByTime(64);
173
+ });
174
+ const box = container.querySelector('.lk-annotate__input');
175
+ box?.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true }));
176
+ expect(wheel).toHaveBeenCalledTimes(1);
177
+ });
178
+
179
+ it('registers a clear for each tile, so a moved mark leaves nothing behind', () => {
180
+ // A proxy: jsdom has no WebGL2, so the clear itself cannot run here. Without
181
+ // one registered, a tile that moves leaves its last frame's marks painted
182
+ // where it used to be — which the surface only erases through a clear.
183
+ const registered = vi.fn();
184
+ render(<Harness onRegisterClear={registered} />, { wrapper: StrictMode });
185
+ act(() => {
186
+ vi.advanceTimersByTime(64);
187
+ });
188
+ expect(new Set(registered.mock.calls.map((call) => call[0])).size).toBe(2);
189
+ });
190
+
191
+ it('never opens the shared context itself when it clears', () => {
192
+ // A bare `getContext('webgl2')` on a buffer nobody has opened yet creates
193
+ // the context with default attributes, and the renderer's later request for
194
+ // a stencil buffer then gets that one back: every mark paints nothing, with
195
+ // no error anywhere. jsdom opens no context, so the call is the proxy.
196
+ const clears: SurfaceClear[] = [];
197
+ const canvas = document.createElement('canvas');
198
+ const { unmount } = render(
199
+ <Harness canvas={canvas} onRegisterClear={(_id, clear) => clears.push(clear)} />,
200
+ { wrapper: StrictMode },
201
+ );
202
+ act(() => {
203
+ vi.advanceTimersByTime(64);
204
+ });
205
+ // The prototype is already a mock file-wide, so the spy arrives holding
206
+ // the renderer's own attempts to open it.
207
+ const open = vi.spyOn(canvas, 'getContext');
208
+ open.mockClear();
209
+ for (const clear of clears) clear({ width: 800, height: 600 }, 1);
210
+ expect(open).not.toHaveBeenCalled();
211
+ // Before the frame stubs come off: the canvas's own loop cancels through them.
212
+ unmount();
213
+ });
214
+
136
215
  it('takes its tiles back out on unmount', () => {
137
216
  const { container, unmount } = render(<Harness />, { wrapper: StrictMode });
138
217
  act(() => {
@@ -206,7 +285,7 @@ describe('two trials of one instrument', () => {
206
285
  handle = surface;
207
286
  return (
208
287
  <SurfaceContext.Provider value={surface}>
209
- <SurfaceCanvasContext.Provider value={null}>
288
+ <SurfaceCanvasContext.Provider value={{ over: null, under: null }}>
210
289
  <div
211
290
  ref={(el) => {
212
291
  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 });
@@ -30,9 +30,9 @@ const TOOLS: Record<string, AnnotationToolInfo> = {
30
30
  text: { weaselTool: 'text', kind: 'text' },
31
31
  };
32
32
 
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. */
33
+ /** The tools a lab's rail gets when any instrument declares `annotations`.
34
+ * Ids share the lab's contribution namespace with its own `tools`, and the
35
+ * lab's tool slot holds whichever is active. */
36
36
  export const ANNOTATION_TOOLS: readonly TrialTool[] = [
37
37
  { id: 'select', label: 'Select', icon: SelectIcon, group: 'annotate' },
38
38
  { id: 'stroke', label: 'Freehand', icon: PencilIcon, group: 'annotate' },
@@ -29,7 +29,7 @@ export interface AnnotationStatus {
29
29
  id: string;
30
30
  label: string;
31
31
  /** What a mark in this status is drawn in. Omitted, it takes the default
32
- * mark colour — a status is allowed to be a label and nothing more. */
32
+ * mark color — a status is allowed to be a label and nothing more. */
33
33
  color?: string;
34
34
  }
35
35
 
@@ -150,9 +150,10 @@ export interface AnnotationTarget extends AnnotationTargetInfo {
150
150
  /** Where an instrument keeps its own marks. Declaring this means labkit never
151
151
  * writes its trial slot — for an instrument whose marks belong in a format it
152
152
  * already owns. Both halves are called outside React; `save` is already
153
- * debounced by the time it arrives. */
153
+ * debounced by the time it arrives. A trial shows an empty body until `load`
154
+ * settles. */
154
155
  export interface AnnotationStorage {
155
- load: () => SerializedAnnotations | null | undefined;
156
+ load: () => Promise<SerializedAnnotations | null | undefined>;
156
157
  save: (doc: SerializedAnnotations) => void;
157
158
  }
158
159
 
@@ -163,6 +164,8 @@ export interface AnnotationsCapability<TS = unknown, TC = unknown> {
163
164
  * is called once per trial, and its targets are that trial's own. */
164
165
  targets: (state: TS, config: TC, trial: TrialInfo) => readonly AnnotationTarget[];
165
166
  meaning?: AnnotationMeaning;
167
+ /** An instrument replacing this one under a live trial must pass the same
168
+ * object: the trial keeps the marks it loaded from the first. */
166
169
  storage?: AnnotationStorage;
167
170
  /** Fires after every finished export, labkit's own chrome included. A
168
171
  * notification, not an interception: a host wanting its own flow calls
@@ -0,0 +1,12 @@
1
+ import { createContext } from 'react';
2
+
3
+ /** Where a trial's camera takes a wheel event from outside its own element. */
4
+ export type CameraWheelSlot = { current: ((e: WheelEvent) => void) | null };
5
+
6
+ /**
7
+ * The trial's camera, for input that lands outside it. An annotation target's
8
+ * input box is portalled into the surface container, so a wheel over a mark
9
+ * never bubbles through the stage or canvas stack it sits on; the box hands
10
+ * the event here instead.
11
+ */
12
+ export const CameraWheelContext = createContext<CameraWheelSlot | null>(null);
@@ -106,3 +106,28 @@ describe('<CanvasStack>', () => {
106
106
  vi.restoreAllMocks();
107
107
  });
108
108
  });
109
+
110
+ describe('<CanvasStack> zoom invariant', () => {
111
+ it('hit-tests a view that arrived with zoom 0 to a finite world point', () => {
112
+ const rect = { left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600 };
113
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(
114
+ rect as unknown as DOMRect,
115
+ );
116
+ const onHitTest = vi.fn();
117
+ const { container } = render(
118
+ <CanvasStack
119
+ layers={makeLayers(1)}
120
+ view={{ zoom: 0, pan: { x: 0, y: 0 } }}
121
+ onViewChange={vi.fn()}
122
+ onHitTest={onHitTest}
123
+ />,
124
+ );
125
+ const host = container.querySelector('.lk-canvas-stack');
126
+ if (!host) throw new Error('no stack host');
127
+ fireEvent.pointerDown(host, { button: 0, pointerId: 1, clientX: 500, clientY: 200 });
128
+ fireEvent.pointerUp(host, { pointerId: 1, clientX: 500, clientY: 200 });
129
+ const world = onHitTest.mock.calls.at(-1)?.[0] as { x: number; y: number };
130
+ expect(Number.isFinite(world.x) && Number.isFinite(world.y)).toBe(true);
131
+ vi.restoreAllMocks();
132
+ });
133
+ });