@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
@@ -7,6 +7,7 @@ import {
7
7
  type WheelEvent,
8
8
  } from 'react';
9
9
  import type { ViewTransform } from '../instrument/types';
10
+ import { normalize2DView } from '../state/view';
10
11
  import { zoomAt } from './camera';
11
12
  import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
12
13
 
@@ -28,7 +29,9 @@ export interface UsePanZoomOptions {
28
29
  }
29
30
 
30
31
  export interface PanZoomHandlers {
31
- onWheel: (e: WheelEvent<HTMLElement>) => void;
32
+ /** Zoom about the pointer. `anchor` is the element the pointer position is
33
+ * measured against, for an event whose `currentTarget` is some other box. */
34
+ onWheel: (e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => void;
32
35
  onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
33
36
  isDragging: () => boolean;
34
37
  }
@@ -56,8 +59,8 @@ export function usePanZoom({
56
59
  }: UsePanZoomOptions): PanZoomHandlers {
57
60
  const dragRef = useRef<DragState | null>(null);
58
61
  const sessionRef = useRef<PointerSession | null>(null);
59
- const viewRef = useRef(view);
60
- viewRef.current = view;
62
+ const viewRef = useRef(normalize2DView(view));
63
+ viewRef.current = normalize2DView(view);
61
64
  const onViewChangeRef = useRef(onViewChange);
62
65
  onViewChangeRef.current = onViewChange;
63
66
  const onTapRef = useRef(onTap);
@@ -67,12 +70,21 @@ export function usePanZoom({
67
70
  // view unreachable behind it.
68
71
  const initialZoomRef = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
69
72
 
73
+ // Written ahead of the render so a second event inside one commit steps
74
+ // from this one; the render-time assignment above still lets the prop win.
75
+ const commit = useCallback((next: ViewTransform) => {
76
+ viewRef.current = normalize2DView(next);
77
+ onViewChangeRef.current(next);
78
+ }, []);
79
+
70
80
  const onWheel = useCallback(
71
- (e: WheelEvent<HTMLElement>) => {
81
+ (e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => {
82
+ const el = anchor ?? (e.currentTarget as Element | null);
83
+ if (!el) return;
72
84
  e.preventDefault();
73
- const rect = e.currentTarget.getBoundingClientRect();
85
+ const rect = el.getBoundingClientRect();
74
86
  const initialZoom = initialZoomRef.current;
75
- onViewChange(
87
+ commit(
76
88
  zoomAt(
77
89
  viewRef.current,
78
90
  Math.exp(-e.deltaY * 0.001),
@@ -85,7 +97,7 @@ export function usePanZoom({
85
97
  ),
86
98
  );
87
99
  },
88
- [onViewChange, minZoom, maxZoom, frame],
100
+ [commit, minZoom, maxZoom, frame],
89
101
  );
90
102
 
91
103
  const clearDrag = useCallback(() => {
@@ -109,7 +121,7 @@ export function usePanZoom({
109
121
  const dy = ev.clientY - drag.startScreenY;
110
122
  if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
111
123
  drag.moved = true;
112
- onViewChangeRef.current({
124
+ commit({
113
125
  zoom: viewRef.current.zoom,
114
126
  pan: { x: drag.startPan.x + dx, y: drag.startPan.y + dy },
115
127
  });
@@ -124,7 +136,7 @@ export function usePanZoom({
124
136
  onCancel: clearDrag,
125
137
  });
126
138
  },
127
- [clearDrag],
139
+ [clearDrag, commit],
128
140
  );
129
141
 
130
142
  useEffect(() => () => sessionRef.current?.cancel(), []);
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
2
  import { CrosshairIcon, HandIcon, PencilIcon, SnapshotIcon } from '@weasel-js/ui';
3
3
  import type { ConfigField } from '../controls/types';
4
4
  import type { Instrument } from '../instrument/types';
@@ -76,7 +76,6 @@ export const EveryRegion: Story = {
76
76
  instruments: [FullInstrument],
77
77
  defaultInstrument: 'Every Region',
78
78
  title: 'Chrome Regions',
79
- storage: null,
80
79
  },
81
80
  };
82
81
 
@@ -86,7 +85,6 @@ export const SuppressedAndReplaced: Story = {
86
85
  instruments: [FullInstrument],
87
86
  defaultInstrument: 'Every Region',
88
87
  title: 'Suppress + Replace',
89
- storage: null,
90
88
  suppress: ['snapshot'],
91
89
  chrome: [
92
90
  {
@@ -116,7 +114,6 @@ export const SubjectTitleAndLead: Story = {
116
114
  instruments: [FullInstrument],
117
115
  defaultInstrument: 'Every Region',
118
116
  title: 'Title Bar Lead',
119
- storage: null,
120
117
  suppress: ['snapshot'],
121
118
  chrome: [
122
119
  {
@@ -0,0 +1,130 @@
1
+ import { useContext, useMemo } from 'react';
2
+ import { useStore } from 'zustand/react';
3
+ import { useLabContext } from '../lab/LabContext';
4
+ import { LabStoreContext } from '../state/context';
5
+ import { usePersistedState } from '../state/usePersistedState';
6
+ import { resolveLabTool } from '../tools/labTool';
7
+ import type { TrialTool } from '../tools/types';
8
+ import type { LabChromeContext, LabContribution, LabRegion } from './labTypes';
9
+ import { mergeContributions } from './merge';
10
+ import { SidebarRegion } from './regions/SidebarRegion';
11
+ import { StatusRegion } from './regions/StatusRegion';
12
+ import { ToolbarRegion } from './regions/ToolbarRegion';
13
+
14
+ /** The lab's chrome context: the lab itself, plus the lab's tool slot. Throws
15
+ * outside a `<Lab>`. */
16
+ export function useLabChromeContext(): LabChromeContext {
17
+ const lab = useLabContext();
18
+ const storeCtx = useContext(LabStoreContext);
19
+ if (!storeCtx) throw new Error('[labkit] lab chrome requires <LabStoreProvider>');
20
+ const slot = useStore(storeCtx.store, (s) => s.activeToolId);
21
+ const storeInstruments = useStore(storeCtx.store, (s) => s.instruments);
22
+ const activeToolId = resolveLabTool(slot, storeInstruments ?? lab.instruments);
23
+ const setLabTool = useStore(storeCtx.store, (s) => s.setLabTool);
24
+ return useMemo(
25
+ () => ({ ...lab, activeToolId, setActiveTool: setLabTool }),
26
+ [lab, activeToolId, setLabTool],
27
+ );
28
+ }
29
+
30
+ /** The lab's contributions for one region, in declaration order. */
31
+ export function contributionsIn<TCtx = LabChromeContext>(
32
+ contributions: readonly LabContribution<TCtx>[],
33
+ region: LabRegion,
34
+ ): LabContribution<TCtx>[] {
35
+ return contributions.filter((c) => c.region === region);
36
+ }
37
+
38
+ /** The palette contributions a lab's `tools` stand for. Declaring a tool is
39
+ * shorthand for contributing a palette item that writes the lab's tool
40
+ * slot. */
41
+ export function toolContributions(tools: readonly TrialTool[]): LabContribution[] {
42
+ return tools.map((t) => ({
43
+ id: t.id,
44
+ region: 'palette' as const,
45
+ ...(t.group === undefined ? {} : { group: t.group }),
46
+ item: {
47
+ icon: t.icon,
48
+ label: t.label,
49
+ ...(t.shortcut === undefined ? {} : { shortcut: t.shortcut }),
50
+ },
51
+ }));
52
+ }
53
+
54
+ /** Everything the lab's chrome renders, from both of the ways a lab declares
55
+ * it. Tools and contributions share one id namespace, so a tool colliding
56
+ * with a contribution throws here rather than one of them silently losing. */
57
+ export function labContributions(
58
+ tools: readonly TrialTool[] | undefined,
59
+ chrome: readonly LabContribution[] | undefined,
60
+ ): LabContribution[] {
61
+ return mergeContributions<LabContribution>(toolContributions(tools ?? []), [...(chrome ?? [])]);
62
+ }
63
+
64
+ /** Props shared by the lab's region mounts. Each takes the lab's whole
65
+ * contribution list and lays out the part addressed to it. */
66
+ export interface LabRegionProps {
67
+ contributions: readonly LabContribution[];
68
+ }
69
+
70
+ /** The lab's action bar, in the shell header beside the title. */
71
+ export function LabHeaderRegion({ contributions }: LabRegionProps) {
72
+ const ctx = useLabChromeContext();
73
+ return (
74
+ <ToolbarRegion
75
+ region="header"
76
+ label="Lab actions"
77
+ contributions={contributionsIn(contributions, 'header')}
78
+ ctx={ctx}
79
+ />
80
+ );
81
+ }
82
+
83
+ function LabSections({ contributions, region }: LabRegionProps & { region: 'sidebar' | 'aside' }) {
84
+ const lab = useLabChromeContext();
85
+ const [collapsedSections, setFolds] = usePersistedState<Record<string, boolean>>(
86
+ `lk-lab-${region}-folds`,
87
+ {},
88
+ { scope: 'lab' },
89
+ );
90
+ const ctx = useMemo(
91
+ () => ({
92
+ ...lab,
93
+ collapsedSections,
94
+ setSectionCollapsed: (key: string, collapsed: boolean) =>
95
+ setFolds((f) => ({ ...f, [key]: collapsed })),
96
+ }),
97
+ [lab, collapsedSections, setFolds],
98
+ );
99
+ return (
100
+ <SidebarRegion
101
+ contributions={contributionsIn(contributions, region)}
102
+ ctx={ctx}
103
+ region={region}
104
+ />
105
+ );
106
+ }
107
+
108
+ /** The lab's sidebar sections. Fold state is a lab-scoped persisted value, so it
109
+ * survives a reload in a stored lab and lasts the session in an unstored one. */
110
+ export function LabSidebarRegion({ contributions }: LabRegionProps) {
111
+ return <LabSections contributions={contributions} region="sidebar" />;
112
+ }
113
+
114
+ /** The lab's aside sections, on the far side of the workspace from the sidebar.
115
+ * Fold state persists the way the sidebar's does. */
116
+ export function LabAsideRegion({ contributions }: LabRegionProps) {
117
+ return <LabSections contributions={contributions} region="aside" />;
118
+ }
119
+
120
+ /** The lab's readouts, in the shell footer below the workspace. */
121
+ export function LabFooterRegion({ contributions }: LabRegionProps) {
122
+ const ctx = useLabChromeContext();
123
+ return (
124
+ <StatusRegion
125
+ region="footer"
126
+ contributions={contributionsIn(contributions, 'footer')}
127
+ ctx={ctx}
128
+ />
129
+ );
130
+ }
@@ -28,6 +28,7 @@ const ctx: TrialChromeContext = {
28
28
  configFields: [],
29
29
  configSchema: fromConfigFields([]),
30
30
  config: {},
31
+ auto: new Set<string>(),
31
32
  setConfig: () => {},
32
33
  savedSnapshots: [],
33
34
  saveSnapshot: () => {},
@@ -12,11 +12,10 @@ import {
12
12
  } from '@weasel-js/ui';
13
13
  import { ExportMenu } from '../annotations/ExportMenu';
14
14
  import { MarkList } from '../annotations/MarkList';
15
- import { ANNOTATION_TOOLS } from '../annotations/toolMap';
16
15
  import type { ControlRenderer } from '../config/types';
17
16
  import { ControlPanel } from '../controls/ControlPanel';
18
17
  import type { Instrument } from '../instrument/types';
19
- import { Select } from '../passthrough/weasel-ui';
18
+ import { MenuButton } from '../passthrough/weasel-ui';
20
19
  import { FpsMeter } from '../primitives/FpsMeter';
21
20
  import { ScaleIndicator } from '../primitives/ScaleIndicator';
22
21
  import { ZoomControl } from '../primitives/ZoomControl';
@@ -82,16 +81,10 @@ export function builtinContributions(
82
81
  });
83
82
  }
84
83
 
85
- // Declaring `annotations` provides the drawing palette, the way declaring
86
- // `tools` provides the instrument's own; both write the same tool slot.
87
84
  // A tool id shares the contribution namespace, so a tool called `close`
88
- // collides with the built-in close button and throws — and so do two tools
89
- // of the same name from these two sources.
90
- const tools = [
91
- ...(instrument.annotations ? ANNOTATION_TOOLS : []),
92
- ...(instrument.tools?.tools ?? []),
93
- ];
94
- for (const t of tools) {
85
+ // collides with the built-in close button and throws. Annotation tools are
86
+ // not here: the lab's rail carries them.
87
+ for (const t of instrument.tools?.tools ?? []) {
95
88
  out.push({
96
89
  id: t.id,
97
90
  region: 'palette',
@@ -116,7 +109,7 @@ export function builtinContributions(
116
109
 
117
110
  // Zoom acts on the view of the trial, so it is a viewport control. A trial
118
111
  // holding a non-2D view reports zoom as null and gets none of this.
119
- if (instrument.canvas != null && zoom !== null) {
112
+ if ((instrument.canvas != null || instrument.stage != null) && zoom !== null) {
120
113
  out.push({
121
114
  id: 'zoom-out',
122
115
  region: 'viewport',
@@ -153,15 +146,17 @@ export function builtinContributions(
153
146
  id: 'scale',
154
147
  region: 'status',
155
148
  group: 'view',
156
- render: () => <ScaleIndicator />,
157
- });
158
- out.push({
159
- id: 'fps',
160
- region: 'status',
161
- group: 'view',
162
- end: true,
163
- render: () => <FpsMeter />,
149
+ render: (c) => <ScaleIndicator zoom={c.zoom ?? 1} />,
164
150
  });
151
+ if (instrument.canvas != null) {
152
+ out.push({
153
+ id: 'fps',
154
+ region: 'status',
155
+ group: 'view',
156
+ end: true,
157
+ render: () => <FpsMeter />,
158
+ });
159
+ }
165
160
  }
166
161
 
167
162
  if (instrument.annotations != null) {
@@ -194,6 +189,7 @@ export function builtinContributions(
194
189
  <ControlPanel
195
190
  schema={ctx.configSchema}
196
191
  config={ctx.config as Record<string, unknown>}
192
+ auto={ctx.auto}
197
193
  setConfig={(key, value) => ctx.setConfig(String(key), value)}
198
194
  renderers={controls}
199
195
  collapsed={groupFolds(ctx.collapsedSections, 'settings')}
@@ -205,24 +201,18 @@ export function builtinContributions(
205
201
  }
206
202
 
207
203
  if (ctx.savedSnapshots.length > 0) {
208
- // A picker is not an icon button, so it takes the render escape. Held at
209
- // `selectedKey={null}` so it stays a "load one" action rather than
210
- // drifting into a display of what was loaded last.
204
+ // A menu is not an icon button, so it takes the render escape.
211
205
  out.push({
212
206
  id: 'snapshot-load',
213
207
  region: 'toolbar',
214
208
  group: 'trial',
215
209
  end: true,
216
210
  render: (c) => (
217
- <Select
218
- className="lk-toolbar__load-select"
211
+ <MenuButton
219
212
  aria-label="Load snapshot"
220
- placeholder="Load…"
221
- selectedKey={null}
222
- options={c.savedSnapshots.map((sn) => ({ value: sn.id, label: sn.name }))}
223
- onSelectionChange={(id) => {
224
- if (id != null) c.loadSnapshot(String(id));
225
- }}
213
+ label="Load…"
214
+ items={c.savedSnapshots.map((sn) => ({ value: sn.id, label: sn.name }))}
215
+ onAction={(id) => c.loadSnapshot(id)}
226
216
  />
227
217
  ),
228
218
  });
@@ -1,4 +1,16 @@
1
1
  export { builtinContributions } from './builtins';
2
+ export type { LabRegionProps } from './LabChrome';
3
+ export {
4
+ contributionsIn,
5
+ LabAsideRegion,
6
+ LabFooterRegion,
7
+ LabHeaderRegion,
8
+ LabSidebarRegion,
9
+ labContributions,
10
+ toolContributions,
11
+ useLabChromeContext,
12
+ } from './LabChrome';
13
+ export type { LabChromeContext, LabContribution, LabRegion } from './labTypes';
2
14
  export { mergeContributions, suppressContributions } from './merge';
3
15
  export type { PaletteRegionProps } from './regions/PaletteRegion';
4
16
  export { PaletteRegion } from './regions/PaletteRegion';
@@ -13,11 +25,15 @@ export { ToolbarRegion } from './regions/ToolbarRegion';
13
25
  export type { ViewportRegionProps } from './regions/ViewportRegion';
14
26
  export { ViewportRegion } from './regions/ViewportRegion';
15
27
  export type {
28
+ ContributionBase,
16
29
  IconComponent,
30
+ RegionContribution,
17
31
  SidebarSection,
32
+ SidebarSlotContext,
18
33
  StatusReadout,
19
34
  ToolbarItem,
20
35
  ToolItem,
36
+ ToolSlotContext,
21
37
  TrialChromeContext,
22
38
  TrialContribution,
23
39
  TrialRegion,
@@ -0,0 +1,208 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import type { Instrument } from '../instrument/types';
4
+ import { Lab } from '../lab/Lab';
5
+ import { LabShell } from '../lab/LabShell';
6
+ import type { LabChromeContext, LabContribution } from './labTypes';
7
+ import { ToolbarRegion } from './regions/ToolbarRegion';
8
+
9
+ const Glyph = () => <svg />;
10
+ const bare: Instrument = {
11
+ name: 'Bare',
12
+ defaultConfig: () => ({}),
13
+ initialState: () => ({}),
14
+ render: () => null,
15
+ };
16
+
17
+ function silenceRenderError(): void {
18
+ vi.spyOn(console, 'error').mockImplementation(() => {});
19
+ }
20
+
21
+ describe('lab-level chrome', () => {
22
+ it('renders a header contribution beside the lab header', () => {
23
+ const contribution: LabContribution = {
24
+ id: 'publish',
25
+ region: 'header',
26
+ item: { icon: Glyph, label: 'Publish', onActivate: () => {} },
27
+ };
28
+ render(
29
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
30
+ );
31
+ expect(screen.getByRole('button', { name: 'Publish' })).toBeInTheDocument();
32
+ });
33
+
34
+ it('hands a header contribution the lab, not a trial', () => {
35
+ const contribution: LabContribution = {
36
+ id: 'another',
37
+ region: 'header',
38
+ item: {
39
+ icon: Glyph,
40
+ label: 'Another',
41
+ onActivate: (ctx) => ctx.addTrial('Bare'),
42
+ },
43
+ };
44
+ render(
45
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
46
+ );
47
+ expect(screen.getAllByLabelText(/^Trial /)).toHaveLength(1);
48
+ fireEvent.click(screen.getByRole('button', { name: 'Another' }));
49
+ expect(screen.getAllByLabelText(/^Trial /)).toHaveLength(2);
50
+ });
51
+
52
+ it('renders a palette contribution in the lab rail and selects it', () => {
53
+ const contribution: LabContribution = {
54
+ id: 'measure',
55
+ region: 'palette',
56
+ item: { icon: Glyph, label: 'Measure' },
57
+ };
58
+ render(
59
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
60
+ );
61
+ const button = screen.getByRole('button', { name: 'Measure' });
62
+ fireEvent.click(button);
63
+ expect(screen.getByRole('button', { name: 'Measure' })).toHaveAttribute('aria-current', 'true');
64
+ });
65
+
66
+ it('gives a palette contribution the whole lab context, not a tool slot alone', () => {
67
+ // The shape the `as unknown as TrialChromeContext` cast used to fake: a
68
+ // contribution reading anything past the tool slot got `undefined`.
69
+ const seen: LabChromeContext[] = [];
70
+ const contribution: LabContribution = {
71
+ id: 'readout',
72
+ region: 'palette',
73
+ render: (ctx) => {
74
+ seen.push(ctx);
75
+ return <span>{`${ctx.trials.length} trial(s), mode ${ctx.mode}`}</span>;
76
+ },
77
+ };
78
+ render(
79
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
80
+ );
81
+ expect(screen.getByText('1 trial(s), mode auto')).toBeInTheDocument();
82
+ expect(seen[0]?.activeToolId).toBeNull();
83
+ expect(typeof seen[0]?.saveSnapshot).toBe('function');
84
+ });
85
+
86
+ it('renders a footer contribution', () => {
87
+ const contribution: LabContribution = {
88
+ id: 'count',
89
+ region: 'footer',
90
+ item: { text: 'idle' },
91
+ };
92
+ render(
93
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
94
+ );
95
+ expect(screen.getByText('idle')).toBeInTheDocument();
96
+ });
97
+
98
+ it('throws when two lab contributions share an id', () => {
99
+ silenceRenderError();
100
+ const clash: LabContribution[] = [
101
+ { id: 'same', region: 'header', item: { icon: Glyph, label: 'One', onActivate: () => {} } },
102
+ { id: 'same', region: 'footer', item: { text: 'two' } },
103
+ ];
104
+ expect(() =>
105
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={clash} />),
106
+ ).toThrow(/duplicate contribution id "same"/);
107
+ });
108
+
109
+ it('keeps the lab tool slot and lab palette contributions in one namespace', () => {
110
+ silenceRenderError();
111
+ const clash: LabContribution = {
112
+ id: 'pick',
113
+ region: 'palette',
114
+ item: { icon: Glyph, label: 'Mine' },
115
+ };
116
+ expect(() =>
117
+ render(
118
+ <Lab
119
+ title="T"
120
+ instruments={[bare]}
121
+ defaultInstrument="Bare"
122
+ tools={[{ id: 'pick', label: 'Pick', icon: Glyph }]}
123
+ labChrome={[clash]}
124
+ />,
125
+ ),
126
+ ).toThrow(/duplicate contribution id "pick"/);
127
+ });
128
+ });
129
+
130
+ describe('lab sidebar region', () => {
131
+ const tree: LabContribution = {
132
+ id: 'tree',
133
+ region: 'sidebar',
134
+ item: { title: 'Stories', body: <p>story list</p> },
135
+ };
136
+
137
+ it('renders a section beside the workspace', () => {
138
+ const { container } = render(
139
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />,
140
+ );
141
+ expect(screen.getByText('story list')).toBeInTheDocument();
142
+ expect(container.querySelector('.lk-lab__sidebar')).not.toBeNull();
143
+ expect(screen.getByRole('separator', { name: /lab sidebar/i })).toBeInTheDocument();
144
+ });
145
+
146
+ it('folds a section', () => {
147
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />);
148
+ fireEvent.click(screen.getByRole('button', { name: 'Stories' }));
149
+ expect(screen.queryByText('story list')).toBeNull();
150
+ });
151
+
152
+ it('offers no tear-out at lab level', () => {
153
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />);
154
+ expect(screen.queryByRole('button', { name: 'Undock Stories' })).toBeNull();
155
+ });
156
+
157
+ it('hands a render contribution the lab context', () => {
158
+ const seen: LabChromeContext[] = [];
159
+ const probe: LabContribution = {
160
+ id: 'probe',
161
+ region: 'sidebar',
162
+ render: (ctx) => {
163
+ seen.push(ctx);
164
+ return <p>probe</p>;
165
+ },
166
+ };
167
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[probe]} />);
168
+ expect(screen.getByText('probe')).toBeInTheDocument();
169
+ expect(seen[0]?.trials).toHaveLength(1);
170
+ });
171
+
172
+ it('adds no strip to a lab without sidebar chrome', () => {
173
+ const { container } = render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" />);
174
+ expect(container.querySelector('.lk-lab__panes')).toBeNull();
175
+ });
176
+ });
177
+
178
+ describe('lab chrome without <Lab>', () => {
179
+ // The rail `apps/theme-editor` mounts: `<LabShell>` with no trial runtime
180
+ // above it, so the context handed to `onActivate` is the consumer's own.
181
+ interface RailCtx {
182
+ undo: () => void;
183
+ }
184
+
185
+ it('renders a contribution typed against a consumer context and hands it back', () => {
186
+ const undo = vi.fn();
187
+ const ctx: RailCtx = { undo };
188
+ const rail: readonly LabContribution<RailCtx>[] = [
189
+ {
190
+ id: 'undo',
191
+ region: 'header',
192
+ item: { icon: Glyph, label: 'Undo', showLabel: true, onActivate: (c) => c.undo() },
193
+ },
194
+ ];
195
+ render(
196
+ <LabShell
197
+ title="Palette lab"
198
+ header={
199
+ <ToolbarRegion region="header" label="Lab actions" contributions={rail} ctx={ctx} />
200
+ }
201
+ >
202
+ <div />
203
+ </LabShell>,
204
+ );
205
+ fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
206
+ expect(undo).toHaveBeenCalledTimes(1);
207
+ });
208
+ });
@@ -0,0 +1,50 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { LabContextValue } from '../lab/LabContext';
3
+ import type {
4
+ ContributionBase,
5
+ SidebarSection,
6
+ StatusReadout,
7
+ ToolbarItem,
8
+ ToolItem,
9
+ ToolSlotContext,
10
+ } from './types';
11
+
12
+ /**
13
+ * A named position in the lab's own chrome — a box the shell owns, outside any
14
+ * one trial. `header` and `footer` are the shell's two bars, named as
15
+ * `<LabShell>` names them; `palette` is the tool rail down the side of the
16
+ * workspace; `sidebar` is a column of sections left of the tool rail.
17
+ */
18
+ export type LabRegion = 'header' | 'palette' | 'sidebar' | 'aside' | 'footer';
19
+
20
+ /**
21
+ * Everything a lab-level contribution can read and command: the lab's own
22
+ * state and operations, plus the lab's tool slot — which every trial whose
23
+ * instrument declares no tools of its own reflects and writes.
24
+ */
25
+ export interface LabChromeContext extends LabContextValue, ToolSlotContext {}
26
+
27
+ /**
28
+ * A contribution to the lab's chrome. Shaped as `TrialContribution` is, and
29
+ * merged and suppressed by the same rules, but keyed to the lab's regions and
30
+ * rendered with the lab's context. Supplying `render` instead of `item` opts
31
+ * out of the region's layout.
32
+ *
33
+ * `TCtx` is what the chrome hands a handler. It defaults to `LabChromeContext`,
34
+ * which is what `<Lab>` renders its regions with; a consumer mounting the
35
+ * regions under a bare `<LabShell>` names whatever it passes as `ctx` instead.
36
+ */
37
+ export type LabContribution<TCtx = LabChromeContext> =
38
+ | (ContributionBase & {
39
+ region: 'header';
40
+ item: ToolbarItem<TCtx>;
41
+ render?: never;
42
+ })
43
+ | (ContributionBase & { region: 'palette'; item: ToolItem; render?: never })
44
+ | (ContributionBase & { region: 'sidebar' | 'aside'; item: SidebarSection; render?: never })
45
+ | (ContributionBase & { region: 'footer'; item: StatusReadout; render?: never })
46
+ | (ContributionBase & {
47
+ region: LabRegion;
48
+ item?: never;
49
+ render: (ctx: TCtx) => ReactNode;
50
+ });
@@ -1,5 +1,3 @@
1
- import type { TrialContribution } from './types';
2
-
3
1
  /**
4
2
  * Concatenate contribution bundles into one list, preserving order — order
5
3
  * decides how a region lays its contributions out, so it is part of the
@@ -8,10 +6,10 @@ import type { TrialContribution } from './types';
8
6
  * Throws on a duplicate id rather than dropping one: a contribution silently
9
7
  * losing to a later bundle is the failure a registry exists to prevent.
10
8
  */
11
- export function mergeContributions(
12
- ...bundles: readonly TrialContribution[][]
13
- ): TrialContribution[] {
14
- const out: TrialContribution[] = [];
9
+ export function mergeContributions<T extends { id: string }>(
10
+ ...bundles: readonly (readonly T[])[]
11
+ ): T[] {
12
+ const out: T[] = [];
15
13
  const seen = new Set<string>();
16
14
  for (const bundle of bundles) {
17
15
  for (const entry of bundle) {
@@ -34,10 +32,10 @@ export function mergeContributions(
34
32
  * silently suppresses nothing is the same class of bug as a duplicate id
35
33
  * silently winning.
36
34
  */
37
- export function suppressContributions(
38
- bundle: readonly TrialContribution[],
35
+ export function suppressContributions<T extends { id: string }>(
36
+ bundle: readonly T[],
39
37
  ids: readonly string[],
40
- ): TrialContribution[] {
38
+ ): T[] {
41
39
  const present = new Set(bundle.map((c) => c.id));
42
40
  for (const id of ids) {
43
41
  if (!present.has(id)) {