@weasel-js/labkit 1.3.0-pre.0 → 1.4.0-pre.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 (259) hide show
  1. package/dist/_dts/CanvasStackContext-DtAn_3O-.d.ts +42 -0
  2. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
  3. package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
  4. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
  5. package/dist/_dts/frac-Cp3NivlC.d.ts +728 -0
  6. package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
  7. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
  8. package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
  9. package/dist/_dts/{types-AHlQxBNN.d.ts → types-D6s4b7if.d.ts} +2 -2
  10. package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
  11. package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
  12. package/dist/canvas/index.d.ts +43 -25
  13. package/dist/canvas/index.js +5 -3
  14. package/dist/chrome/index.d.ts +9 -6
  15. package/dist/chrome/index.js +7 -6
  16. package/dist/chunk-2AYGEN57.js +32 -0
  17. package/dist/chunk-2AYGEN57.js.map +1 -0
  18. package/dist/{chunk-RVNN4CHQ.js → chunk-3P5TTTA7.js} +16 -8
  19. package/dist/chunk-3P5TTTA7.js.map +1 -0
  20. package/dist/{chunk-NS54R52R.js → chunk-3ZELRRBV.js} +45 -40
  21. package/dist/chunk-3ZELRRBV.js.map +1 -0
  22. package/dist/{chunk-HFDVGF4X.js → chunk-4EY67BKJ.js} +32 -7
  23. package/dist/chunk-4EY67BKJ.js.map +1 -0
  24. package/dist/chunk-5TCEVMPI.js +140 -0
  25. package/dist/chunk-5TCEVMPI.js.map +1 -0
  26. package/dist/{chunk-O7NVSCLN.js → chunk-6JMU56CU.js} +133 -134
  27. package/dist/chunk-6JMU56CU.js.map +1 -0
  28. package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
  29. package/dist/chunk-CQLQPQ4P.js.map +1 -0
  30. package/dist/chunk-CTRKTLYZ.js +33 -0
  31. package/dist/chunk-CTRKTLYZ.js.map +1 -0
  32. package/dist/chunk-EQIB2PGC.js +458 -0
  33. package/dist/chunk-EQIB2PGC.js.map +1 -0
  34. package/dist/chunk-HEORBLVT.js +7128 -0
  35. package/dist/chunk-HEORBLVT.js.map +1 -0
  36. package/dist/{chunk-V7PRSIRQ.js → chunk-JDDIZSLL.js} +4 -4
  37. package/dist/chunk-JDDIZSLL.js.map +1 -0
  38. package/dist/chunk-LITE5YFB.js +669 -0
  39. package/dist/chunk-LITE5YFB.js.map +1 -0
  40. package/dist/{chunk-Z6HJ5FEM.js → chunk-ROQDRXKW.js} +732 -315
  41. package/dist/chunk-ROQDRXKW.js.map +1 -0
  42. package/dist/chunk-SBXNQW4G.js +3 -0
  43. package/dist/chunk-SBXNQW4G.js.map +1 -0
  44. package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
  45. package/dist/chunk-XJ6N32QP.js.map +1 -0
  46. package/dist/chunk-YQJNT4XQ.js +293 -0
  47. package/dist/chunk-YQJNT4XQ.js.map +1 -0
  48. package/dist/controls/index.d.ts +4 -28
  49. package/dist/controls/index.js +3 -3
  50. package/dist/dragdrop/index.d.ts +50 -5
  51. package/dist/dragdrop/index.js +3 -1
  52. package/dist/index.d.ts +660 -302
  53. package/dist/index.js +1793 -587
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +6 -5
  56. package/dist/layers/index.js +4 -2
  57. package/dist/loupe/index.d.ts +187 -0
  58. package/dist/loupe/index.js +7 -0
  59. package/dist/loupe/index.js.map +1 -0
  60. package/dist/passthrough/weasel-canvas.d.ts +3 -1
  61. package/dist/passthrough/weasel-canvas.js +1 -1
  62. package/dist/passthrough/weasel-ui.d.ts +151 -91
  63. package/dist/passthrough/weasel-ui.js +3 -2
  64. package/dist/primitives/index.d.ts +3 -1
  65. package/dist/primitives/index.js +6 -5
  66. package/dist/state/index.d.ts +3 -3
  67. package/dist/state/index.js +2 -2
  68. package/dist/styles.css +229 -703
  69. package/dist/surface/index.d.ts +18 -2
  70. package/dist/surface/index.js +2 -2
  71. package/dist/ui/layers/index.d.ts +8 -2
  72. package/dist/ui/layers/index.js +2 -4
  73. package/dist/undo/index.d.ts +5 -4
  74. package/package.json +14 -7
  75. package/src/annotations/AnnotationOverlay.tsx +205 -0
  76. package/src/annotations/AnnotationTargets.tsx +37 -0
  77. package/src/annotations/Annotations.less +129 -0
  78. package/src/annotations/Annotations.meaning.test.tsx +111 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +142 -0
  80. package/src/annotations/AnnotationsContext.ts +49 -0
  81. package/src/annotations/ExportMenu.test.tsx +86 -0
  82. package/src/annotations/ExportMenu.tsx +182 -0
  83. package/src/annotations/MarkList.tsx +75 -0
  84. package/src/annotations/capture.test.ts +140 -0
  85. package/src/annotations/capture.ts +237 -0
  86. package/src/annotations/drawOne.test.ts +56 -0
  87. package/src/annotations/drawOne.ts +47 -0
  88. package/src/annotations/frac.test.ts +59 -0
  89. package/src/annotations/frac.ts +66 -0
  90. package/src/annotations/history.test.ts +82 -0
  91. package/src/annotations/history.ts +118 -0
  92. package/src/annotations/index.ts +37 -0
  93. package/src/annotations/paint.test.ts +125 -0
  94. package/src/annotations/paint.ts +130 -0
  95. package/src/annotations/staleness.test.ts +56 -0
  96. package/src/annotations/staleness.ts +41 -0
  97. package/src/annotations/store.test.ts +175 -0
  98. package/src/annotations/store.ts +317 -0
  99. package/src/annotations/svgNodes.test.ts +77 -0
  100. package/src/annotations/svgNodes.ts +74 -0
  101. package/src/annotations/toolMap.test.ts +36 -0
  102. package/src/annotations/toolMap.ts +54 -0
  103. package/src/annotations/types.ts +222 -0
  104. package/src/annotations/view.test.ts +37 -0
  105. package/src/annotations/view.ts +40 -0
  106. package/src/canvas/AGENTS.md +55 -5
  107. package/src/canvas/CanvasStack.test.tsx +27 -1
  108. package/src/canvas/CanvasStack.tsx +29 -4
  109. package/src/canvas/CanvasStackContext.ts +23 -4
  110. package/src/canvas/camera.test.ts +78 -0
  111. package/src/canvas/camera.ts +62 -0
  112. package/src/canvas/canvasCoords.test.ts +26 -0
  113. package/src/canvas/canvasCoords.ts +20 -7
  114. package/src/canvas/index.ts +5 -1
  115. package/src/canvas/useLayerScheduler.ts +7 -3
  116. package/src/canvas/usePanZoom.test.ts +36 -5
  117. package/src/canvas/usePanZoom.ts +20 -15
  118. package/src/canvas/worldSpec.test.ts +63 -0
  119. package/src/canvas/worldSpec.ts +48 -0
  120. package/src/chrome/builtins.test.ts +31 -1
  121. package/src/chrome/builtins.tsx +63 -19
  122. package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
  123. package/src/chrome/regions/PaletteRegion.tsx +4 -0
  124. package/src/chrome/regions/SidebarRegion.tsx +37 -13
  125. package/src/chrome/regions/ToolbarRegion.tsx +2 -1
  126. package/src/chrome/regions/ViewportRegion.tsx +9 -1
  127. package/src/chrome/regions/regions.test.tsx +6 -1
  128. package/src/chrome/types.ts +20 -0
  129. package/src/config/builder.test.ts +7 -1
  130. package/src/config/builder.ts +1 -5
  131. package/src/config/resolve.test.ts +1 -2
  132. package/src/controls/ControlPanel.stories.tsx +41 -2
  133. package/src/controls/ControlPanel.test.tsx +69 -9
  134. package/src/controls/ControlPanel.tsx +106 -25
  135. package/src/dragdrop/DragDropRuntime.tsx +8 -2
  136. package/src/dragdrop/dragDrop.test.tsx +29 -2
  137. package/src/dragdrop/index.ts +11 -0
  138. package/src/index.test.ts +22 -0
  139. package/src/index.ts +136 -4
  140. package/src/instrument/SineWave.smoke.test.tsx +1 -0
  141. package/src/instrument/types.ts +24 -1
  142. package/src/lab/Lab.surface.test.tsx +162 -0
  143. package/src/lab/Lab.tsx +102 -17
  144. package/src/lab/Lab.undock.test.tsx +85 -0
  145. package/src/lab/LabFullChrome.stories.tsx +20 -0
  146. package/src/lab/LabShell.less +11 -0
  147. package/src/lab/Workspace.less +73 -0
  148. package/src/lab/Workspace.surface.test.tsx +2 -0
  149. package/src/lab/Workspace.tsx +97 -8
  150. package/src/lab/index.ts +1 -1
  151. package/src/lab/panelHost.ts +37 -0
  152. package/src/layers/AGENTS.md +1 -1
  153. package/src/layers/LayerList.tsx +1 -1
  154. package/src/loupe/AGENTS.md +49 -0
  155. package/src/loupe/CanvasLoupe.tsx +83 -0
  156. package/src/loupe/DomLoupe.tsx +62 -0
  157. package/src/loupe/Loupe.less +38 -0
  158. package/src/loupe/LoupeBubble.tsx +32 -0
  159. package/src/loupe/TrialLoupe.tsx +93 -0
  160. package/src/loupe/canvasLens.test.ts +205 -0
  161. package/src/loupe/canvasLens.ts +141 -0
  162. package/src/loupe/index.ts +19 -0
  163. package/src/loupe/types.test.ts +29 -0
  164. package/src/loupe/types.ts +90 -0
  165. package/src/loupe/useHostSize.ts +30 -0
  166. package/src/loupe/useLoupe.test.tsx +197 -0
  167. package/src/loupe/useLoupe.ts +189 -0
  168. package/src/passthrough/weasel-ui.ts +25 -17
  169. package/src/primitives/FloatingPanel.test.tsx +76 -1
  170. package/src/primitives/FloatingPanel.tsx +19 -3
  171. package/src/primitives/ScaleIndicator.test.tsx +7 -2
  172. package/src/primitives/Sidebar.less +18 -0
  173. package/src/primitives/Toolbar.less +8 -0
  174. package/src/primitives/Toolbar.tsx +5 -0
  175. package/src/primitives/useRovingTabIndex.test.ts +21 -0
  176. package/src/primitives/useRovingTabIndex.ts +26 -17
  177. package/src/state/document.test.ts +23 -0
  178. package/src/state/document.ts +14 -2
  179. package/src/state/index.ts +2 -0
  180. package/src/state/store.test.ts +24 -0
  181. package/src/state/store.ts +35 -1
  182. package/src/state/types.ts +11 -0
  183. package/src/state/undock.test.ts +40 -0
  184. package/src/state/undock.ts +39 -0
  185. package/src/styles.less +2 -4
  186. package/src/surface/SurfaceContext.ts +4 -0
  187. package/src/surface/index.ts +8 -3
  188. package/src/surface/useSurfaceTile.test.tsx +2 -0
  189. package/src/surface/useSurfaceTile.ts +7 -1
  190. package/src/surface/useTiledSurface.test.tsx +58 -0
  191. package/src/surface/useTiledSurface.ts +38 -3
  192. package/src/theme/Interstellar.stories.tsx +1 -1
  193. package/src/trial/Trial.annotations.persist.test.tsx +166 -0
  194. package/src/trial/Trial.annotations.test.tsx +121 -0
  195. package/src/trial/Trial.canvas.test.tsx +76 -1
  196. package/src/trial/Trial.config.test.tsx +3 -8
  197. package/src/trial/Trial.less +19 -0
  198. package/src/trial/Trial.loupe.test.tsx +128 -0
  199. package/src/trial/Trial.stories.tsx +1 -0
  200. package/src/trial/Trial.test.tsx +12 -0
  201. package/src/trial/Trial.trialId.test.tsx +36 -0
  202. package/src/trial/Trial.tsx +215 -40
  203. package/src/trial/TrialChrome.tsx +35 -0
  204. package/src/trial/UndockedSections.tsx +65 -0
  205. package/src/trial/trialOps.test.ts +21 -0
  206. package/src/trial/trialOps.ts +5 -3
  207. package/src/ui/layers/index.ts +5 -1
  208. package/dist/_dts/index-DgcNdEdh.d.ts +0 -314
  209. package/dist/chunk-4TMMVIDM.js.map +0 -1
  210. package/dist/chunk-CPUJ3QXL.js.map +0 -1
  211. package/dist/chunk-HFDVGF4X.js.map +0 -1
  212. package/dist/chunk-NS54R52R.js.map +0 -1
  213. package/dist/chunk-O2BULFHX.js +0 -152
  214. package/dist/chunk-O2BULFHX.js.map +0 -1
  215. package/dist/chunk-O7NVSCLN.js.map +0 -1
  216. package/dist/chunk-PO6L3NSH.js +0 -366
  217. package/dist/chunk-PO6L3NSH.js.map +0 -1
  218. package/dist/chunk-PQJ5B2U2.js +0 -27
  219. package/dist/chunk-PQJ5B2U2.js.map +0 -1
  220. package/dist/chunk-RVNN4CHQ.js.map +0 -1
  221. package/dist/chunk-TLGRYALP.js +0 -6151
  222. package/dist/chunk-TLGRYALP.js.map +0 -1
  223. package/dist/chunk-V7PRSIRQ.js.map +0 -1
  224. package/dist/chunk-Z6HJ5FEM.js.map +0 -1
  225. package/dist/chunk-ZZAYVX4X.js +0 -505
  226. package/dist/chunk-ZZAYVX4X.js.map +0 -1
  227. package/src/primitives/DragHandleGlyph.tsx +0 -29
  228. package/src/ui/format.test.ts +0 -33
  229. package/src/ui/format.ts +0 -30
  230. package/src/ui/layers/LayerStack.less +0 -143
  231. package/src/ui/layers/LayerStack.stories.tsx +0 -45
  232. package/src/ui/layers/LayerStack.test.tsx +0 -170
  233. package/src/ui/layers/LayerStack.tsx +0 -208
  234. package/src/ui/properties/CheckboxRow.stories.tsx +0 -27
  235. package/src/ui/properties/ColorRow.stories.tsx +0 -106
  236. package/src/ui/properties/CurveField.less +0 -48
  237. package/src/ui/properties/CurveField.stories.tsx +0 -20
  238. package/src/ui/properties/CurveField.test.tsx +0 -127
  239. package/src/ui/properties/CurveField.tsx +0 -184
  240. package/src/ui/properties/EffectCard.tsx +0 -324
  241. package/src/ui/properties/Gallery.stories.tsx +0 -178
  242. package/src/ui/properties/NumberRow.stories.tsx +0 -65
  243. package/src/ui/properties/PropertyGroup.less +0 -50
  244. package/src/ui/properties/PropertyGroup.stories.tsx +0 -34
  245. package/src/ui/properties/PropertyGroup.test.tsx +0 -33
  246. package/src/ui/properties/PropertyGroup.tsx +0 -45
  247. package/src/ui/properties/PropertyList.stories.tsx +0 -83
  248. package/src/ui/properties/PropertyPanel.less +0 -606
  249. package/src/ui/properties/PropertyPanel.stories.tsx +0 -190
  250. package/src/ui/properties/PropertyPanel.test.tsx +0 -193
  251. package/src/ui/properties/PropertyPanel.tsx +0 -469
  252. package/src/ui/properties/PropertyRow.stories.tsx +0 -53
  253. package/src/ui/properties/SelectRow.stories.tsx +0 -58
  254. package/src/ui/properties/SliderRow.stories.tsx +0 -74
  255. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +0 -398
  256. package/src/ui/properties/TextRow.stories.tsx +0 -62
  257. package/src/ui/properties/ToggleRow.stories.tsx +0 -57
  258. package/src/ui/properties/index.ts +0 -39
  259. package/src/ui/properties/storyLayouts.tsx +0 -36
@@ -50,8 +50,18 @@ interface SurfaceHandle {
50
50
  * moved something a ResizeObserver cannot see. */
51
51
  invalidateRects: () => void;
52
52
  registerTile: (id: string, el: HTMLElement | null) => void;
53
+ /** Subscribe a tile to the frames it is dirty on. A tile paints on its own
54
+ * loop, so this is how the surface wakes one: a resize of the shared buffer
55
+ * clears every tile, not only the one that moved. */
56
+ registerPainter: (id: string, paint: TilePainter) => () => void;
53
57
  containerRef: (el: HTMLElement | null) => void;
58
+ /** The element tile rects are measured against, and so the one a tile's own
59
+ * chrome positions itself inside. Null before the owner attaches it. */
60
+ getContainer: () => HTMLElement | null;
54
61
  }
62
+ /** What a tile's painter is handed: where it sits on the surface now, and the
63
+ * frame that dirtied it. */
64
+ type TilePainter = (rect: Rect, frame: SurfaceFrame) => void;
55
65
  interface UseTiledSurfaceOptions {
56
66
  onFrame: (frame: SurfaceFrame) => void;
57
67
  }
@@ -59,7 +69,13 @@ declare function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHa
59
69
 
60
70
  /** Null when no surface owner is above — a lab with no shared surface at all. */
61
71
  declare const SurfaceContext: react.Context<SurfaceHandle | null>;
72
+ /** The buffer the surface's tiles paint into. Null until the owner's canvas is
73
+ * mounted, and for an owner that keeps its surface unpainted. */
74
+ declare const SurfaceCanvasContext: react.Context<HTMLCanvasElement | null>;
62
75
 
76
+ /** The surface's shared buffer, or null where nothing paints one. What a tile
77
+ * hands to `paintInto`. */
78
+ declare function useSurfaceCanvas(): HTMLCanvasElement | null;
63
79
  /** The surface above, or null. Use this where a surface is genuinely optional. */
64
80
  declare function useSurfaceOptional(): SurfaceHandle | null;
65
81
  /** The surface above. Throws where a caller cannot work without one. */
@@ -73,5 +89,5 @@ declare function useSurface(): SurfaceHandle;
73
89
  */
74
90
  declare function useSurfaceTile(id: string): (el: HTMLElement | null) => void;
75
91
 
76
- export { SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceOptional, useSurfaceTile, useTiledSurface };
77
- export type { Box, Rect, SurfaceFrame, SurfaceHandle, UseTiledSurfaceOptions };
92
+ export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTiledSurface };
93
+ export type { Box, Rect, SurfaceFrame, SurfaceHandle, TilePainter, UseTiledSurfaceOptions };
@@ -1,4 +1,4 @@
1
- export { SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceOptional, useSurfaceTile, useTiledSurface } from '../chunk-HFDVGF4X.js';
2
- import '../chunk-Z6HJ5FEM.js';
1
+ export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTiledSurface } from '../chunk-4EY67BKJ.js';
2
+ import '../chunk-ROQDRXKW.js';
3
3
  //# sourceMappingURL=index.js.map
4
4
  //# sourceMappingURL=index.js.map
@@ -14,7 +14,9 @@ interface LayerStackItem {
14
14
  * can switch mode/shape without expanding. */
15
15
  primaryValue?: string;
16
16
  primaryOptions?: string[];
17
- /** Accent CSS color used as the left border / index-badge fill. */
17
+ /** Accent CSS color used as the left border / index-badge fill. Sets
18
+ * --wzl-layer-stack-accent on the card, which re-binds --wzl-accent
19
+ * for everything inside it. */
18
20
  accent?: string;
19
21
  /** Optional badge text rendered before the primary control
20
22
  * (e.g. tail index "1", "2", "3"). When omitted a drag handle
@@ -38,10 +40,14 @@ interface LayerStackProps {
38
40
  /** Hide the title + palette row (used when an outer wrap renders its
39
41
  * own head — see speech-balloons Tails panel). */
40
42
  hideHead?: boolean;
43
+ /** Appended to the root element's class list. The module's own class names
44
+ * are hashed, so this is the supported way to reach the stack from a
45
+ * consumer stylesheet. */
46
+ className?: string;
41
47
  }
42
48
  /** A drag-reorderable stack of expandable cards, with a palette in the header
43
49
  * for adding more. The body of each card is the caller's to render. */
44
- declare function LayerStack({ title, items, paletteKinds, onAdd, onRemove, onReorder, onPrimaryChange, renderBody, hideHead, }: LayerStackProps): react_jsx_runtime.JSX.Element;
50
+ declare function LayerStack({ className, title, items, paletteKinds, onAdd, onRemove, onReorder, onPrimaryChange, renderBody, hideHead, }: LayerStackProps): react_jsx_runtime.JSX.Element;
45
51
 
46
52
  export { LayerStack };
47
53
  export type { LayerStackItem, LayerStackProps };
@@ -1,6 +1,4 @@
1
- export { LayerStack } from '../../chunk-O2BULFHX.js';
2
- import '../../chunk-TLGRYALP.js';
3
- import '../../chunk-Z6HJ5FEM.js';
4
- import '../../chunk-PQJ5B2U2.js';
1
+ export { c3 as LayerStack } from '../../chunk-HEORBLVT.js';
2
+ import '../../chunk-ROQDRXKW.js';
5
3
  //# sourceMappingURL=index.js.map
6
4
  //# sourceMappingURL=index.js.map
@@ -1,9 +1,10 @@
1
- export { U as UndoCapability } from '../_dts/index-DgcNdEdh.js';
1
+ export { a0 as UndoCapability } from '../_dts/frac-Cp3NivlC.js';
2
2
  import 'react';
3
- import '../_dts/types-AHlQxBNN.js';
4
- import '../_dts/PrefsForm-BYa6cWnO.js';
5
- import '../_dts/types-1Sdxy_Pv.js';
3
+ import '../_dts/types-D6s4b7if.js';
4
+ import '../_dts/PrefsForm-BkUJZx0A.js';
5
+ import '../_dts/types-DYMaEvM5.js';
6
6
  import '../_dts/types-DJ79Tg5J.js';
7
+ import '../_dts/types-C-gh9Ap-.js';
7
8
 
8
9
  /** A subscriber to a named event. */
9
10
  type EventListener = () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.3.0-pre.0",
3
+ "version": "1.4.0-pre.0",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -82,7 +82,11 @@
82
82
  "types": "./dist/ui/layers/index.d.ts",
83
83
  "import": "./dist/ui/layers/index.js"
84
84
  },
85
- "./styles.css": "./dist/styles.css"
85
+ "./styles.css": "./dist/styles.css",
86
+ "./loupe": {
87
+ "types": "./dist/loupe/index.d.ts",
88
+ "import": "./dist/loupe/index.js"
89
+ }
86
90
  },
87
91
  "scripts": {
88
92
  "dev": "vite",
@@ -102,16 +106,19 @@
102
106
  "docs:dev": "vitepress dev docs",
103
107
  "docs:build": "vitepress build docs",
104
108
  "docs:preview": "vitepress preview docs",
105
- "dev:schema": "LABKIT_EXAMPLE=schema-lab vite"
109
+ "dev:schema": "LABKIT_EXAMPLE=schema-lab vite",
110
+ "dev:annotate": "LABKIT_EXAMPLE=annotate-lab vite"
106
111
  },
107
112
  "peerDependencies": {
108
113
  "react": "^19.0.0",
109
114
  "react-dom": "^19.0.0"
110
115
  },
111
116
  "dependencies": {
112
- "@weasel-js/core": "1.3.0-pre.0",
113
- "@weasel-js/theme": "1.3.0-pre.0",
114
- "@weasel-js/ui": "1.3.0-pre.0",
117
+ "@weasel-js/core": "1.4.0-pre.0",
118
+ "@weasel-js/loupe": "1.4.0-pre.0",
119
+ "@weasel-js/svg": "1.4.0-pre.0",
120
+ "@weasel-js/theme": "1.4.0-pre.0",
121
+ "@weasel-js/ui": "1.4.0-pre.0",
115
122
  "earcut": "2.2.4",
116
123
  "polygon-clipping": "^0.15.7",
117
124
  "react-aria-components": "^1.5.0",
@@ -122,7 +129,7 @@
122
129
  "@biomejs/biome": "^2.4.12",
123
130
  "@rollup/plugin-alias": "^6.0.0",
124
131
  "@testing-library/jest-dom": "^6.6.0",
125
- "@weasel-js/modes": "1.3.0-pre.0",
132
+ "@weasel-js/modes": "1.4.0-pre.0",
126
133
  "less": "^4.2.0",
127
134
  "rollup-plugin-dts": "^6.4.1",
128
135
  "tsx": "^4.19.0",
@@ -0,0 +1,205 @@
1
+ import {
2
+ type InsertNodeFactory,
3
+ SceneCanvas,
4
+ type SceneCanvasApi,
5
+ useActiveToolContext,
6
+ type View,
7
+ WeaselProvider,
8
+ } from '@weasel-js/core';
9
+ import { type CSSProperties, useEffect, useRef, useState } from 'react';
10
+ import { createPortal } from 'react-dom';
11
+ import type { Rect } from '../surface/rect';
12
+ import { useSurfaceCanvas, useSurfaceOptional } from '../surface/useSurfaceTile';
13
+ import { createMarkDrawOne } from './drawOne';
14
+ import type { WorldRect } from './frac';
15
+ import { seenFrom } from './staleness';
16
+ import type { MarkScene } from './store';
17
+ import { ANNOTATION_WEASEL_TOOLS, annotationToolInfo } from './toolMap';
18
+ import type {
19
+ AnnotationData,
20
+ AnnotationKind,
21
+ AnnotationMeaning,
22
+ AnnotationTarget,
23
+ FracPoint,
24
+ } from './types';
25
+ import { fitView, toWeaselView } from './view';
26
+
27
+ /** Props for `<AnnotationOverlay>`. */
28
+ export interface AnnotationOverlayProps {
29
+ target: AnnotationTarget;
30
+ /** The trial's mark scene — shared by every target; a mark's own `target`
31
+ * field is what separates them. */
32
+ scene: MarkScene;
33
+ /** Snapshotted onto each new mark, so staleness can be answered later. */
34
+ config: unknown;
35
+ /** The trial's resolved tool slot. */
36
+ activeToolId: string | null;
37
+ /** The instrument's vocabulary, for a status's colour. */
38
+ meaning?: AnnotationMeaning;
39
+ }
40
+
41
+ const sameRect = (a: Rect | null, b: Rect): boolean =>
42
+ a !== null && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
43
+
44
+ /** Pushes the trial's tool into the weasel scope this pane owns. Its own
45
+ * component because the context only exists below `<WeaselProvider>`. */
46
+ function ToolBridge({ toolId }: { toolId: string }) {
47
+ const tools = useActiveToolContext();
48
+ const setActive = tools.setActive;
49
+ useEffect(() => {
50
+ setActive(toolId);
51
+ }, [toolId, setActive]);
52
+ return null;
53
+ }
54
+
55
+ /** Every vertex the insert carried, in fractions of the content box. */
56
+ function pointsOf(
57
+ extras: Record<string, unknown>,
58
+ content: { w: number; h: number },
59
+ ): readonly FracPoint[] | undefined {
60
+ const toFrac = (p: { x: number; y: number }): FracPoint => ({
61
+ x: content.w === 0 ? 0 : p.x / content.w,
62
+ y: content.h === 0 ? 0 : p.y / content.h,
63
+ });
64
+ if (extras.kind === 'line') {
65
+ return [toFrac(extras.a as FracPoint), toFrac(extras.b as FracPoint)];
66
+ }
67
+ if (extras.kind === 'pencil') {
68
+ return (extras.samples as { x: number; y: number }[]).map(toFrac);
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ /**
74
+ * One target's drawing surface: a transparent box over the target's element
75
+ * taking the input, and a `<SceneCanvas>` painting into the lab's shared
76
+ * buffer at that box's rect.
77
+ *
78
+ * The box is portalled into the surface container because that is what tile
79
+ * rects are measured against; positioning it among the instrument's own DOM
80
+ * would put it in whatever positioning context happened to be there.
81
+ */
82
+ export function AnnotationOverlay({
83
+ target,
84
+ scene,
85
+ config,
86
+ activeToolId,
87
+ meaning,
88
+ }: AnnotationOverlayProps) {
89
+ const surface = useSurfaceOptional();
90
+ const canvas = useSurfaceCanvas();
91
+ const [rect, setRect] = useState<Rect | null>(null);
92
+ const [input, setInput] = useState<HTMLDivElement | null>(null);
93
+ const sceneCanvas = useRef<SceneCanvasApi | null>(null);
94
+
95
+ // Read in the insert factory, which is built once and must see the tool the
96
+ // pane is holding now — `arrow` and `stroke` are indistinguishable from
97
+ // `line` and `pencil` at the weasel end.
98
+ const toolRef = useRef(activeToolId);
99
+ toolRef.current = activeToolId;
100
+ const configRef = useRef(config);
101
+ configRef.current = config;
102
+
103
+ const id = target.id;
104
+ const lastTile = useRef<HTMLElement | null>(null);
105
+ useEffect(() => {
106
+ const el = target.ref.current ?? null;
107
+ if (el === lastTile.current) return;
108
+ lastTile.current = el;
109
+ surface?.registerTile(id, el);
110
+ });
111
+ useEffect(
112
+ () => () => {
113
+ // Clearing this is what lets the effect above re-register on the next
114
+ // commit. StrictMode's mount / unmount / mount would otherwise take the
115
+ // tile out and never put it back, and the surface would measure nothing.
116
+ lastTile.current = null;
117
+ surface?.registerTile(id, null);
118
+ },
119
+ [surface, id],
120
+ );
121
+
122
+ useEffect(
123
+ () =>
124
+ surface?.registerPainter(id, (next) => {
125
+ setRect((prev) => (sameRect(prev, next) ? prev : next));
126
+ // The shared buffer was cleared if it resized this frame; this pane's
127
+ // own loop has no way to know that.
128
+ sceneCanvas.current?.requestRedraw();
129
+ }),
130
+ [surface, id],
131
+ );
132
+
133
+ const container = surface?.getContainer() ?? null;
134
+ if (!container || !rect) return null;
135
+
136
+ const view: View = toWeaselView(target.view ?? fitView(target.content, { w: rect.w, h: rect.h }));
137
+
138
+ const drawOne = createMarkDrawOne({
139
+ content: target.content,
140
+ positionDependsOn: target.positionDependsOn,
141
+ config,
142
+ meaning,
143
+ });
144
+
145
+ const factories: Record<string, InsertNodeFactory> = {};
146
+ for (const weaselTool of ANNOTATION_WEASEL_TOOLS) {
147
+ factories[weaselTool] = (_bounds, extras) => {
148
+ const info = annotationToolInfo(toolRef.current);
149
+ const kind: AnnotationKind | undefined = info?.kind;
150
+ if (!kind) return null;
151
+ const points = pointsOf(extras as unknown as Record<string, unknown>, target.content);
152
+ const data: AnnotationData = {
153
+ target: id,
154
+ kind,
155
+ ...(points ? { points } : {}),
156
+ seen: seenFrom(configRef.current, target.positionDependsOn ?? []),
157
+ };
158
+ return { data };
159
+ };
160
+ }
161
+
162
+ // A measured rect has nowhere to live but the element's own style. Set as
163
+ // custom properties, which the stylesheet reads — the sanctioned use.
164
+ const box = {
165
+ ['--lk-anno-x' as string]: `${rect.x}px`,
166
+ ['--lk-anno-y' as string]: `${rect.y}px`,
167
+ ['--lk-anno-w' as string]: `${rect.w}px`,
168
+ ['--lk-anno-h' as string]: `${rect.h}px`,
169
+ } as CSSProperties;
170
+
171
+ return createPortal(
172
+ <>
173
+ <div
174
+ ref={setInput}
175
+ className="lk-annotate__input"
176
+ data-annotation-target={id}
177
+ role="application"
178
+ aria-label={`Annotations on ${id}`}
179
+ // 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.
180
+ tabIndex={0}
181
+ style={box}
182
+ />
183
+ {canvas && input ? (
184
+ // One scope per pane: a shared <ActionsProvider> lets only the newest
185
+ // canvas under it respond to input, and the rest go silently dead.
186
+ <WeaselProvider isolate>
187
+ <ToolBridge toolId={annotationToolInfo(activeToolId)?.weaselTool ?? 'select'} />
188
+ <SceneCanvas<AnnotationData, 'marks', WorldRect>
189
+ ref={sceneCanvas}
190
+ scene={scene}
191
+ width={rect.w}
192
+ height={rect.h}
193
+ view={view}
194
+ paintInto={{ canvas, x: rect.x, y: rect.y }}
195
+ inputElement={input}
196
+ defaultTools={ANNOTATION_WEASEL_TOOLS}
197
+ insertNodeFactories={factories}
198
+ layers={{ scene: { drawOne } }}
199
+ />
200
+ </WeaselProvider>
201
+ ) : null}
202
+ </>,
203
+ container,
204
+ );
205
+ }
@@ -0,0 +1,37 @@
1
+ import { AnnotationOverlay } from './AnnotationOverlay';
2
+ import type { AnnotationsApi, AnnotationsCapability } from './types';
3
+
4
+ /** Props for `<AnnotationTargets>`. */
5
+ export interface AnnotationTargetsProps {
6
+ capability: AnnotationsCapability;
7
+ state: unknown;
8
+ config: unknown;
9
+ annotations: AnnotationsApi;
10
+ activeToolId: string | null;
11
+ }
12
+
13
+ /** One overlay per region the instrument said accepts marks. */
14
+ export function AnnotationTargets({
15
+ capability,
16
+ state,
17
+ config,
18
+ annotations,
19
+ activeToolId,
20
+ }: AnnotationTargetsProps) {
21
+ const targets = capability.targets(state, config);
22
+
23
+ return (
24
+ <>
25
+ {targets.map((t) => (
26
+ <AnnotationOverlay
27
+ key={t.id}
28
+ target={t}
29
+ scene={annotations.sceneFor(t.id)}
30
+ meaning={capability.meaning}
31
+ config={config}
32
+ activeToolId={activeToolId}
33
+ />
34
+ ))}
35
+ </>
36
+ );
37
+ }
@@ -0,0 +1,129 @@
1
+ // The box a target takes its marks' input from: transparent, over the target's
2
+ // element, positioned in the surface container the tile rects are measured
3
+ // against. Above the shared buffer, because the buffer takes no input at all.
4
+ .lk-annotate__input {
5
+ position: absolute;
6
+ left: var(--lk-anno-x);
7
+ top: var(--lk-anno-y);
8
+ width: var(--lk-anno-w);
9
+ height: var(--lk-anno-h);
10
+ z-index: 2;
11
+ }
12
+
13
+ // The sidebar list of marks. One row per mark: what it is, where it is, what
14
+ // it says, and whether it still describes the picture under it.
15
+ .lk-mark-list {
16
+ list-style: none;
17
+ margin: 0;
18
+ padding: 0;
19
+ display: flex;
20
+ flex-direction: column;
21
+ gap: var(--wzl-space-xs);
22
+ }
23
+
24
+ .lk-mark-list__empty {
25
+ color: var(--wzl-fg-muted);
26
+ font-size: var(--wzl-font-size-sm);
27
+ }
28
+
29
+ .lk-mark-list__row {
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: var(--wzl-space-xs);
33
+ padding: var(--wzl-space-xs);
34
+ border: 1px solid var(--wzl-border);
35
+ border-radius: var(--wzl-radius-sm);
36
+ background: var(--wzl-surface-raised);
37
+ }
38
+
39
+ .lk-mark-list__head {
40
+ display: flex;
41
+ align-items: center;
42
+ gap: var(--wzl-space-xs);
43
+ font-size: var(--wzl-font-size-sm);
44
+ }
45
+
46
+ .lk-mark-list__target {
47
+ color: var(--wzl-fg-muted);
48
+ }
49
+
50
+ .lk-mark-list__stale {
51
+ color: var(--wzl-warning);
52
+ font-size: var(--wzl-font-size-xs);
53
+ text-transform: uppercase;
54
+ }
55
+
56
+ .lk-mark-list__drop {
57
+ margin-left: auto;
58
+ background: none;
59
+ border: 0;
60
+ color: var(--wzl-fg-muted);
61
+ cursor: pointer;
62
+ }
63
+
64
+ // The export panel: a target to export, how, and at what resolution. Held in
65
+ // a popover because a mark belongs to a target — with more than one pane,
66
+ // "export" on its own does not say what.
67
+ .lk-export__panel {
68
+ display: grid;
69
+ grid-template-columns: auto 1fr;
70
+ align-items: center;
71
+ gap: var(--wzl-space-xs) var(--wzl-space-sm);
72
+ min-width: 220px;
73
+ font-size: var(--wzl-font-size-sm);
74
+ }
75
+
76
+ .lk-export__title {
77
+ grid-column: 1 / -1;
78
+ font-weight: var(--wzl-font-weight-medium);
79
+ }
80
+
81
+ // A row spans both columns and re-declares the same tracks, so every row's
82
+ // label lines up whether it is a <label> or a <fieldset>: subgrid is what
83
+ // keeps one column definition rather than three copies.
84
+ .lk-export__row {
85
+ display: grid;
86
+ grid-column: 1 / -1;
87
+ grid-template-columns: subgrid;
88
+ align-items: center;
89
+ margin: 0;
90
+ padding: 0;
91
+ border: 0;
92
+
93
+ > span:first-child {
94
+ grid-column: 1;
95
+ grid-row: 1;
96
+ color: var(--wzl-fg-muted);
97
+ }
98
+
99
+ > select {
100
+ grid-column: 2;
101
+ }
102
+ }
103
+
104
+ .lk-export__choices {
105
+ grid-column: 2;
106
+ grid-row: 1;
107
+ display: flex;
108
+ gap: var(--wzl-space-sm);
109
+
110
+ label {
111
+ display: inline-flex;
112
+ align-items: center;
113
+ gap: 3px;
114
+ }
115
+ }
116
+
117
+ .lk-export__actions {
118
+ grid-column: 1 / -1;
119
+ display: flex;
120
+ gap: var(--wzl-space-xs);
121
+ margin-top: var(--wzl-space-xs);
122
+ }
123
+
124
+ .lk-export__error {
125
+ grid-column: 1 / -1;
126
+ margin: 0;
127
+ color: var(--wzl-danger);
128
+ font-size: var(--wzl-font-size-xs);
129
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The meaning tier: what a mark says, as opposed to where it is. The store
3
+ * already carries `title` / `status` / `tags`; this is the chrome over them,
4
+ * plus the two things a reader needs at a glance — which target a mark is on,
5
+ * and whether it still describes the picture under it.
6
+ */
7
+ import { fireEvent, render, screen } from '@testing-library/react';
8
+ import { describe, expect, it } from 'vitest';
9
+ import { AnnotationsContext } from './AnnotationsContext';
10
+ import { MarkList } from './MarkList';
11
+ import { createAnnotationStore } from './store';
12
+ import type { AnnotationMeaning, AnnotationsApi, AnnotationTargetInfo } from './types';
13
+
14
+ const TARGETS: AnnotationTargetInfo[] = [
15
+ { id: 'flat', content: { w: 200, h: 100 }, positionDependsOn: ['angle'] },
16
+ { id: 'shaded', content: { w: 200, h: 100 } },
17
+ ];
18
+
19
+ const MEANING: AnnotationMeaning = {
20
+ statuses: [
21
+ { id: 'open', label: 'Open', color: '#e5484d' },
22
+ { id: 'fixed', label: 'Fixed', color: '#30a46c' },
23
+ ],
24
+ };
25
+
26
+ function mount(store: AnnotationsApi, meaning?: AnnotationMeaning, config: unknown = { angle: 0 }) {
27
+ return render(
28
+ <AnnotationsContext.Provider value={store}>
29
+ <MarkList meaning={meaning} config={config} />
30
+ </AnnotationsContext.Provider>,
31
+ );
32
+ }
33
+
34
+ function seeded() {
35
+ const store = createAnnotationStore({ targets: () => TARGETS });
36
+ store.add(
37
+ {
38
+ target: 'flat',
39
+ kind: 'rect',
40
+ frac: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 },
41
+ title: 'spurious ring',
42
+ status: 'open',
43
+ },
44
+ { angle: 0 },
45
+ );
46
+ store.add({ target: 'shaded', kind: 'line', frac: { x: 0.4, y: 0.4, w: 0.2, h: 0 } }, {});
47
+ return store;
48
+ }
49
+
50
+ describe('<MarkList>', () => {
51
+ it('lists every mark, with the target it is on', () => {
52
+ mount(seeded(), MEANING);
53
+ const rows = screen.getAllByRole('listitem');
54
+ expect(rows).toHaveLength(2);
55
+ expect(rows[0]?.textContent).toContain('flat');
56
+ expect(rows[1]?.textContent).toContain('shaded');
57
+ });
58
+
59
+ it('writes a status through the store, and re-renders from it', () => {
60
+ const store = seeded();
61
+ mount(store, MEANING);
62
+ const picker = screen.getAllByRole('combobox')[0];
63
+ if (!picker) throw new Error('no status picker');
64
+ fireEvent.change(picker, { target: { value: 'fixed' } });
65
+
66
+ expect(store.query({ status: 'fixed' })).toHaveLength(1);
67
+ // The list is not holding its own copy: the value it shows came back out
68
+ // of the store, which is what makes a mark drawn on the canvas appear here.
69
+ expect((screen.getAllByRole('combobox')[0] as HTMLSelectElement).value).toBe('fixed');
70
+ });
71
+
72
+ it('renames a mark through the store', () => {
73
+ const store = seeded();
74
+ mount(store, MEANING);
75
+ const field = screen.getAllByRole('textbox')[0];
76
+ if (!field) throw new Error('no title field');
77
+ fireEvent.change(field, { target: { value: 'missing edge' } });
78
+ expect(store.query({ where: (a) => a.title === 'missing edge' })).toHaveLength(1);
79
+ });
80
+
81
+ it("marks a row stale when its target's declared config moved", () => {
82
+ const store = seeded();
83
+ // `flat` snapshotted angle 0 and declares it; `shaded` declares nothing
84
+ // and can never go stale.
85
+ mount(store, MEANING, { angle: 30 });
86
+ const rows = screen.getAllByRole('listitem');
87
+ expect(rows[0]?.textContent).toContain('stale');
88
+ expect(rows[1]?.textContent).not.toContain('stale');
89
+ });
90
+
91
+ it('drops a mark on request', () => {
92
+ const store = seeded();
93
+ mount(store, MEANING);
94
+ fireEvent.click(screen.getAllByRole('button', { name: /remove/i })[0] as HTMLElement);
95
+ expect(store.query()).toHaveLength(1);
96
+ expect(screen.getAllByRole('listitem')).toHaveLength(1);
97
+ });
98
+
99
+ it('still lists marks for an instrument that declared no vocabulary', () => {
100
+ // No `meaning` is "this host owns what a mark means", not "hide the list".
101
+ mount(seeded());
102
+ expect(screen.getAllByRole('listitem')).toHaveLength(2);
103
+ expect(screen.queryAllByRole('combobox')).toHaveLength(0);
104
+ });
105
+
106
+ it('says so when there are none', () => {
107
+ mount(createAnnotationStore({ targets: () => TARGETS }), MEANING);
108
+ expect(screen.queryAllByRole('listitem')).toHaveLength(0);
109
+ expect(screen.getByText(/no marks/i)).toBeTruthy();
110
+ });
111
+ });