@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
@@ -1,7 +1,12 @@
1
- import { type ReactNode, useContext, useMemo, useRef, useState } from 'react';
1
+ import { type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { useStore } from 'zustand/react';
3
+ import { AnnotationsContext } from '../annotations/AnnotationsContext';
4
+ import { AnnotationTargets } from '../annotations/AnnotationTargets';
5
+ import { annotationsFromJSON } from '../annotations/store';
6
+ import type { AnnotationTargetInfo } from '../annotations/types';
3
7
  import { CanvasStack } from '../canvas/CanvasStack';
4
8
  import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
9
+ import { applyCamera, type ViewportSize } from '../canvas/worldSpec';
5
10
  import type { TrialContribution } from '../chrome/types';
6
11
  import { DragOverlay, useDragDrop } from '../dragdrop/DragDropRuntime';
7
12
  import { Palette } from '../dragdrop/Palette';
@@ -9,13 +14,15 @@ import type { Instrument, LayerDescriptor, PaletteItem, RenderContext } from '..
9
14
  import { useJob } from '../job/useJob';
10
15
  import { useLabContext } from '../lab/LabContext';
11
16
  import { LayerList } from '../layers/LayerList';
12
- import { LabStoreContext } from '../state/context';
17
+ import { TrialLoupe } from '../loupe/TrialLoupe';
18
+ import { resolveLoupe } from '../loupe/types';
19
+ import { LabStoreContext, TrialIdProvider } from '../state/context';
13
20
  import type { LabStore } from '../state/store';
14
21
  import type { TrialRecord } from '../state/types';
15
22
  import { as2DView, DEFAULT_VIEW } from '../state/view';
16
23
  import { createEventBus, type EventBus } from '../undo/eventBus';
17
24
  import { pushSnapshot, redo as undoRedo, undo as undoUndo } from '../undo/undoStack';
18
- import type { UndoBindings } from './TrialChrome';
25
+ import type { LoupeBindings, UndoBindings } from './TrialChrome';
19
26
  import { TrialChrome } from './TrialChrome';
20
27
 
21
28
  /** Props for `<Trial>`. */
@@ -67,10 +74,12 @@ interface TrialRuntimeProps {
67
74
 
68
75
  function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: TrialRuntimeProps) {
69
76
  const canvasContainerRef = useRef<HTMLDivElement | null>(null);
77
+ const loupeHostRef = useRef<HTMLDivElement | null>(null);
70
78
  const updateTrialState = useStore(store, (s) => s.updateTrialState);
71
79
  const updateTrialConfig = useStore(store, (s) => s.updateTrialConfig);
72
80
  const updateTrialView = useStore(store, (s) => s.updateTrialView);
73
81
  const updateTrialUndoStack = useStore(store, (s) => s.updateTrialUndoStack);
82
+ const updateTrialAnnotations = useStore(store, (s) => s.updateTrialAnnotations);
74
83
  const labToolId = useStore(store, (s) => s.activeToolId);
75
84
  const setLabTool = useStore(store, (s) => s.setLabTool);
76
85
  const setTrialTool = useStore(store, (s) => s.setTrialTool);
@@ -81,6 +90,15 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
81
90
 
82
91
  const [layerVisibility, setLayerVisibility] = useState<Record<string, boolean>>({});
83
92
  const [layerOrder, setLayerOrder] = useState<string[] | null>(null);
93
+ const [loupeOn, setLoupeOn] = useState(false);
94
+
95
+ const visibleLayers = useMemo(
96
+ () =>
97
+ (instrument.canvas?.layers ?? [])
98
+ .filter((l) => layerVisibility[l.id] !== false)
99
+ .map((l) => l.id),
100
+ [instrument.canvas, layerVisibility],
101
+ );
84
102
 
85
103
  const undoCap = instrument.undo;
86
104
  const undoEvents = useMemo(() => new Set(undoCap?.snapshotOn ?? ['state.change']), [undoCap]);
@@ -96,11 +114,101 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
96
114
 
97
115
  const setView = (v: unknown): void => updateTrialView(record.id, v);
98
116
 
117
+ // A function `initialView` needs the canvas size, so `trialOps` leaves the
118
+ // view null and the first measurement resolves it. Reset nulls it again,
119
+ // which re-frames — so the guard is the null itself, not a "have I run" flag.
120
+ const placeView = (size: ViewportSize): void => {
121
+ const declared = instrument.canvas?.initialView;
122
+ if (typeof declared !== 'function') return;
123
+ if (record.view != null) return;
124
+ setView(declared(size));
125
+ };
126
+
127
+ // The capability's own `targets` thunk, re-read on every call: a target
128
+ // resizing or gaining a dependency must not need the store rebuilt. Held in
129
+ // a ref because the store is built once and closes over it.
130
+ const annotationsCap = instrument.annotations;
131
+ const targetsRef = useRef<() => readonly AnnotationTargetInfo[]>(() => []);
132
+ targetsRef.current = () =>
133
+ annotationsCap ? annotationsCap.targets(record.state, record.config) : [];
134
+
135
+ // One store for the trial's lifetime. Marks do not survive a reload yet —
136
+ // the storage slot is 3d.
137
+ const annotationsRef = useRef<ReturnType<typeof annotationsFromJSON> | null>(null);
138
+ // Read through refs for the same reason `targets` is: the store is built
139
+ // once, and an export must draw against the config the trial holds now.
140
+ const configRef = useRef<unknown>(record.config);
141
+ configRef.current = record.config;
142
+ if (annotationsRef.current === null) {
143
+ // Seeded from wherever the marks were kept: the instrument's own store if
144
+ // it declared one, else this trial's slot.
145
+ const kept = annotationsCap?.storage ? annotationsCap.storage.load() : record.annotations;
146
+ annotationsRef.current = annotationsFromJSON(kept, () => targetsRef.current(), {
147
+ meaning: annotationsCap?.meaning,
148
+ config: () => configRef.current,
149
+ onCapture: (result) => annotationsCap?.onCapture?.(result),
150
+ });
151
+ }
152
+ const annotations = annotationsRef.current;
153
+
154
+ // Written on a trailing debounce rather than per notification: a scene
155
+ // mutates every frame of a drag, and a zustand write per frame re-renders
156
+ // every trial in the lab. The unmount flush is not an optimization — the
157
+ // last mark before a trial closes is otherwise lost.
158
+ const saveCap = annotationsCap?.storage;
159
+ const saveRef = useRef<(doc: unknown) => void>(() => {});
160
+ saveRef.current = (doc) => {
161
+ if (saveCap) saveCap.save(doc as never);
162
+ else updateTrialAnnotations(record.id, doc);
163
+ };
164
+ // Only when the pair flips, which is rare: bumping React state on every
165
+ // scene notification would re-render the trial on every frame of a drag,
166
+ // which is the thing the debounce below exists to avoid.
167
+ const [markMoves, setMarkMoves] = useState({ undo: false, redo: false });
168
+ useEffect(() => {
169
+ if (!annotationsCap) return;
170
+ const read = (): void => {
171
+ const next = { undo: annotations.canUndo(), redo: annotations.canRedo() };
172
+ setMarkMoves((prev) => (prev.undo === next.undo && prev.redo === next.redo ? prev : next));
173
+ };
174
+ read();
175
+ return annotations.subscribe(read);
176
+ }, [annotations, annotationsCap]);
177
+
178
+ useEffect(() => {
179
+ if (!annotationsCap) return;
180
+ let timer: ReturnType<typeof setTimeout> | null = null;
181
+ let dirty = false;
182
+ const flush = (): void => {
183
+ if (!dirty) return;
184
+ dirty = false;
185
+ saveRef.current(annotations.toJSON());
186
+ };
187
+ const off = annotations.subscribe(() => {
188
+ dirty = true;
189
+ if (timer) clearTimeout(timer);
190
+ timer = setTimeout(() => {
191
+ timer = null;
192
+ flush();
193
+ }, 250);
194
+ });
195
+ return () => {
196
+ off();
197
+ if (timer) clearTimeout(timer);
198
+ flush();
199
+ };
200
+ }, [annotations, annotationsCap]);
201
+
99
202
  // A trial gets its own slot when its instrument declares tools; otherwise it
100
203
  // reads the lab's. Which slot a change writes follows from the same thing.
101
- const declaresTools = instrument.tools != null;
204
+ // Annotation tools are a trial's own for the same reason: two trials
205
+ // annotating different pictures must not share one active tool.
206
+ const declaresTools = instrument.tools != null || instrument.annotations != null;
102
207
  const resolvedToolId = declaresTools
103
- ? (record.activeToolId ?? instrument.tools?.initial ?? instrument.tools?.tools[0]?.id ?? null)
208
+ ? (record.activeToolId ??
209
+ instrument.tools?.initial ??
210
+ instrument.tools?.tools[0]?.id ??
211
+ (instrument.annotations ? 'select' : null))
104
212
  : labToolId;
105
213
  const setActiveTool = (id: string): void => {
106
214
  if (declaresTools) setTrialTool(record.id, id);
@@ -147,6 +255,7 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
147
255
  updateTrialView(record.id, { ...view2d, zoom: z });
148
256
  },
149
257
  activeToolId: resolvedToolId,
258
+ visibleLayers,
150
259
  },
151
260
  emit: (event) => {
152
261
  snapshotIfNeeded(event);
@@ -155,23 +264,46 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
155
264
  job: jobCap ? job : undefined,
156
265
  };
157
266
 
158
- const undoBindings: UndoBindings | undefined = undoCap
159
- ? {
160
- canUndo: record.undoStack.past.length > 0,
161
- canRedo: record.undoStack.future.length > 0,
162
- undo: () => {
163
- const result = undoUndo(record.undoStack, structuredClone(record.state));
164
- if (!result) return;
165
- updateTrialState(record.id, result.snapshot as never);
166
- updateTrialUndoStack(record.id, result.stack);
167
- },
168
- redo: () => {
169
- const result = undoRedo(record.undoStack, structuredClone(record.state));
170
- if (!result) return;
171
- updateTrialState(record.id, result.snapshot as never);
172
- updateTrialUndoStack(record.id, result.stack);
173
- },
174
- }
267
+ // Marks are undone through weasel history, not through labkit's snapshot
268
+ // stack — the spec's rule, and the reason a mark scene is the truth. A trial
269
+ // declaring both takes the marks first: the most recent thing the user did
270
+ // is what undo is for, and only a mark change moves the mark history.
271
+ const undoState = () => {
272
+ const result = undoUndo(record.undoStack, structuredClone(record.state));
273
+ if (!result) return;
274
+ updateTrialState(record.id, result.snapshot as never);
275
+ updateTrialUndoStack(record.id, result.stack);
276
+ };
277
+ const redoState = () => {
278
+ const result = undoRedo(record.undoStack, structuredClone(record.state));
279
+ if (!result) return;
280
+ updateTrialState(record.id, result.snapshot as never);
281
+ updateTrialUndoStack(record.id, result.stack);
282
+ };
283
+
284
+ const undoBindings: UndoBindings | undefined =
285
+ undoCap || annotationsCap
286
+ ? {
287
+ canUndo: markMoves.undo || (undoCap ? record.undoStack.past.length > 0 : false),
288
+ canRedo: markMoves.redo || (undoCap ? record.undoStack.future.length > 0 : false),
289
+ undo: () => {
290
+ if (annotationsCap && annotations.undo()) return;
291
+ if (undoCap) undoState();
292
+ },
293
+ redo: () => {
294
+ if (annotationsCap && annotations.redo()) return;
295
+ if (undoCap) redoState();
296
+ },
297
+ }
298
+ : undefined;
299
+
300
+ const loupeCap = useMemo(() => {
301
+ const declared = instrument.loupe;
302
+ if (declared == null) return null;
303
+ return resolveLoupe(typeof declared === 'function' ? declared(record.config) : declared);
304
+ }, [instrument.loupe, record.config]);
305
+ const loupeBindings: LoupeBindings | undefined = loupeCap
306
+ ? { on: loupeOn, toggle: () => setLoupeOn((v) => !v) }
175
307
  : undefined;
176
308
 
177
309
  const canvasLayers: CanvasLayerDescriptor[] = useMemo(() => {
@@ -183,11 +315,10 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
183
315
  return ordered.map((layer) => ({
184
316
  id: layer.id,
185
317
  visible: layerVisibility[layer.id] !== false,
186
- render: (ctx, view) => {
318
+ render: (ctx, view, frame) => {
187
319
  // Camera applied here, so a layer draws in world coordinates. `zoom`
188
320
  // stays in the args for line widths, which must not scale with it.
189
- ctx.translate(view.pan.x, view.pan.y);
190
- ctx.scale(view.zoom, view.zoom);
321
+ applyCamera(ctx, view, frame);
191
322
  layer.draw(ctx, { state: record.state, config: record.config, zoom: view.zoom });
192
323
  },
193
324
  }));
@@ -202,6 +333,7 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
202
333
  capability: instrument.dragDrop ?? { palette: [], onDrop: (_p, _i, s) => s },
203
334
  canvasContainerRef,
204
335
  view: view2d ?? DEFAULT_VIEW,
336
+ worldSpec: instrument.canvas?.worldSpec,
205
337
  state: record.state,
206
338
  config: record.config,
207
339
  setState: (next) => {
@@ -245,6 +377,20 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
245
377
  ];
246
378
  }, [canvasLayers, dragDropResult.drag]);
247
379
 
380
+ // The lens tracks the pointer over whatever box holds the content: the canvas
381
+ // stack's own element, which it takes from context, or the wrapper below.
382
+ const lens = loupeCap ? (
383
+ <TrialLoupe
384
+ capability={loupeCap}
385
+ enabled={loupeOn}
386
+ state={record.state}
387
+ config={record.config}
388
+ view={view2d ?? DEFAULT_VIEW}
389
+ worldSpec={instrument.canvas?.worldSpec}
390
+ hostRef={loupeHostRef}
391
+ />
392
+ ) : null;
393
+
248
394
  let body: ReactNode;
249
395
  if (instrument.canvas) {
250
396
  body = (
@@ -253,14 +399,24 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
253
399
  layers={layersWithFeedback}
254
400
  view={view2d ?? DEFAULT_VIEW}
255
401
  onViewChange={setView}
402
+ worldSpec={instrument.canvas.worldSpec}
403
+ onResize={placeView}
256
404
  minZoom={instrument.canvas.minZoom}
257
405
  maxZoom={instrument.canvas.maxZoom}
258
406
  >
259
407
  {instrument.render(renderCtx)}
408
+ {lens}
260
409
  </CanvasStack>
261
410
  <DragOverlay drag={dragDropResult.drag} />
262
411
  </div>
263
412
  );
413
+ } else if (lens) {
414
+ body = (
415
+ <div ref={loupeHostRef} className="lk-trial__loupe-host">
416
+ {instrument.render(renderCtx)}
417
+ {lens}
418
+ </div>
419
+ );
264
420
  } else {
265
421
  body = instrument.render(renderCtx);
266
422
  }
@@ -310,21 +466,40 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
310
466
  return out;
311
467
  }, [paletteNode, layerListNode]);
312
468
 
313
- return (
314
- <TrialChrome
315
- job={jobCap ? job : undefined}
316
- trialId={record.id}
317
- record={record}
318
- instrument={instrument}
319
- isLastTrial={isLast}
320
- undoBindings={undoBindings}
321
- trialChrome={extraChrome}
322
- chrome={chrome}
323
- suppress={suppress}
469
+ // Rendered beside the body rather than inside it: an overlay portals itself
470
+ // into the surface container, so where it sits in this tree decides only
471
+ // when its effects run — after the instrument's refs have attached.
472
+ const annotationOverlays = annotationsCap ? (
473
+ <AnnotationTargets
474
+ capability={annotationsCap}
475
+ state={record.state}
476
+ config={record.config}
477
+ annotations={annotations}
324
478
  activeToolId={resolvedToolId}
325
- setActiveTool={setActiveTool}
326
- >
327
- {body}
328
- </TrialChrome>
479
+ />
480
+ ) : null;
481
+
482
+ return (
483
+ <TrialIdProvider trialId={record.id}>
484
+ <AnnotationsContext.Provider value={annotationsCap ? annotations : null}>
485
+ <TrialChrome
486
+ job={jobCap ? job : undefined}
487
+ loupe={loupeBindings}
488
+ trialId={record.id}
489
+ record={record}
490
+ instrument={instrument}
491
+ isLastTrial={isLast}
492
+ undoBindings={undoBindings}
493
+ trialChrome={extraChrome}
494
+ chrome={chrome}
495
+ suppress={suppress}
496
+ activeToolId={resolvedToolId}
497
+ setActiveTool={setActiveTool}
498
+ >
499
+ {body}
500
+ {annotationOverlays}
501
+ </TrialChrome>
502
+ </AnnotationsContext.Provider>
503
+ </TrialIdProvider>
329
504
  );
330
505
  }
@@ -18,6 +18,13 @@ import { LabStoreContext } from '../state/context';
18
18
  import type { TrialRecord } from '../state/types';
19
19
  import { as2DView } from '../state/view';
20
20
  import { TrialTitleBar } from './TrialTitleBar';
21
+ import { UndockedSections } from './UndockedSections';
22
+
23
+ /** The trial's loupe as its chrome sees it: a switch and its position. */
24
+ export interface LoupeBindings {
25
+ on: boolean;
26
+ toggle: () => void;
27
+ }
21
28
 
22
29
  export interface UndoBindings {
23
30
  canUndo: boolean;
@@ -35,6 +42,9 @@ export interface TrialChromeProps {
35
42
  undoBindings?: UndoBindings;
36
43
  /** Supplied when the instrument declares a `job`. */
37
44
  job?: JobHandle;
45
+ /** Supplied when the instrument declares a `loupe`. The lens itself is the
46
+ * trial's to mount; the chrome only offers the switch. */
47
+ loupe?: LoupeBindings;
38
48
  /** Contributions the trial runtime itself adds — the drag palette and the
39
49
  * layer list, which depend on runtime state the instrument cannot reach. */
40
50
  trialChrome?: readonly TrialContribution[];
@@ -61,6 +71,7 @@ export function TrialChrome({
61
71
  isLastTrial,
62
72
  undoBindings,
63
73
  job,
74
+ loupe,
64
75
  trialChrome,
65
76
  chrome,
66
77
  suppress,
@@ -74,6 +85,16 @@ export function TrialChrome({
74
85
  const updateTrialView = useStore(storeCtx.store, (s) => s.updateTrialView);
75
86
  const updateTrialConfig = useStore(storeCtx.store, (s) => s.updateTrialConfig);
76
87
  const updateTrialState = useStore(storeCtx.store, (s) => s.updateTrialState);
88
+ const undockPanelAction = useStore(storeCtx.store, (s) => s.undockPanel);
89
+ const dockPanelAction = useStore(storeCtx.store, (s) => s.dockPanel);
90
+ const undockedPanels = useStore(storeCtx.store, (s) => s.undockedPanels);
91
+ const undockedIds = useMemo(
92
+ () =>
93
+ Object.values(undockedPanels)
94
+ .filter((p) => p.trialId === trialId)
95
+ .map((p) => p.sectionId),
96
+ [undockedPanels, trialId],
97
+ );
77
98
 
78
99
  // The trial view is opaque to labkit, so the zoom chrome asks for the 2D shape
79
100
  // and goes inert when the trial holds something else — an orbit, say.
@@ -96,6 +117,8 @@ export function TrialChrome({
96
117
  canRedo: undoBindings?.canRedo ?? false,
97
118
  undo: undoBindings?.undo ?? NO_OP,
98
119
  redo: undoBindings?.redo ?? NO_OP,
120
+ loupeOn: loupe?.on ?? false,
121
+ toggleLoupe: loupe?.toggle ?? NO_OP,
99
122
  configSchema,
100
123
  configFields: instrument.config ? [] : (instrument.configSchema?.() ?? []),
101
124
  config: record.config,
@@ -113,6 +136,9 @@ export function TrialChrome({
113
136
  updateTrialState(trialId, nextState as never);
114
137
  }
115
138
  },
139
+ undockedPanels: undockedIds,
140
+ undockPanel: (sectionId, as) => undockPanelAction(trialId, sectionId, as),
141
+ dockPanel: (sectionId) => dockPanelAction(trialId, sectionId),
116
142
  savedSnapshots: lab.savedSnapshots.filter((s) => s.trialId === trialId),
117
143
  saveSnapshot: (name) => lab.saveSnapshot(trialId, name),
118
144
  loadSnapshot: (snapshotId) => lab.loadSnapshot(trialId, snapshotId),
@@ -132,10 +158,14 @@ export function TrialChrome({
132
158
  updateTrialConfig,
133
159
  updateTrialState,
134
160
  undoBindings,
161
+ loupe,
135
162
  view2d,
136
163
  activeToolId,
137
164
  setActiveTool,
138
165
  configSchema,
166
+ undockedIds,
167
+ undockPanelAction,
168
+ dockPanelAction,
139
169
  ]);
140
170
 
141
171
  const contributions = useMemo(
@@ -185,6 +215,11 @@ export function TrialChrome({
185
215
  <PaletteRegion contributions={inRegion('palette')} ctx={ctx} />
186
216
  <div className="lk-trial__sidebar">
187
217
  <SidebarRegion contributions={inRegion('sidebar')} ctx={ctx} />
218
+ <UndockedSections
219
+ trialId={trialId}
220
+ sections={inRegion('sidebar').filter((c) => undockedIds.includes(c.id))}
221
+ onDock={ctx.dockPanel}
222
+ />
188
223
  </div>
189
224
  <div className={`lk-trial__content${instrument.canvas ? ' lk-trial__content--flush' : ''}`}>
190
225
  {children}
@@ -0,0 +1,65 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import type { SidebarSection, TrialContribution } from '../chrome/types';
4
+ import { usePanelHosts } from '../lab/panelHost';
5
+ import { panelKey } from '../state/undock';
6
+
7
+ /** Props for `<UndockedSections>`. */
8
+ export interface UndockedSectionsProps {
9
+ trialId: string;
10
+ /** The trial's sidebar contributions that are currently torn out. */
11
+ sections: readonly TrialContribution[];
12
+ onDock: (sectionId: string) => void;
13
+ }
14
+
15
+ function Portalled({
16
+ trialId,
17
+ contribution,
18
+ onDock,
19
+ }: {
20
+ trialId: string;
21
+ contribution: TrialContribution;
22
+ onDock: (sectionId: string) => void;
23
+ }) {
24
+ const hosts = usePanelHosts();
25
+ const key = panelKey(trialId, contribution.id);
26
+ const host = useSyncExternalStore(
27
+ (fn) => hosts?.subscribe(fn) ?? (() => {}),
28
+ () => hosts?.get(key) ?? null,
29
+ () => null,
30
+ );
31
+ if (!host || !contribution.item) return null;
32
+ const item = contribution.item as SidebarSection;
33
+ return createPortal(
34
+ <>
35
+ <div className="lk-panel-tile__titlebar">
36
+ <span className="lk-panel-tile__title">{item.title}</span>
37
+ <button
38
+ type="button"
39
+ className="lk-panel-tile__dock"
40
+ aria-label={`Dock ${item.title}`}
41
+ title={`Dock ${item.title}`}
42
+ onClick={() => onDock(contribution.id)}
43
+ >
44
+ ⇤
45
+ </button>
46
+ </div>
47
+ {item.body}
48
+ </>,
49
+ host,
50
+ );
51
+ }
52
+
53
+ /** Renders a torn-out section's body into the workspace panel holding it. The
54
+ * body stays mounted in the trial's React tree — only its DOM moves — so it
55
+ * keeps the trial's context and its own state across an undock. */
56
+ export function UndockedSections({ trialId, sections, onDock }: UndockedSectionsProps) {
57
+ if (sections.length === 0) return null;
58
+ return (
59
+ <>
60
+ {sections.map((c) => (
61
+ <Portalled key={c.id} trialId={trialId} contribution={c} onDock={onDock} />
62
+ ))}
63
+ </>
64
+ );
65
+ }
@@ -167,3 +167,24 @@ describe('reorderTrials', () => {
167
167
  expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
168
168
  });
169
169
  });
170
+
171
+ describe('a size-aware initial view', () => {
172
+ const sized = {
173
+ name: 'Sized',
174
+ defaultConfig: () => ({}),
175
+ initialState: () => ({}),
176
+ render: () => null,
177
+ canvas: {
178
+ initialView: ({ width, height }: { width: number; height: number }) => ({
179
+ zoom: 1,
180
+ pan: { x: width / 2, y: height / 2 },
181
+ }),
182
+ layers: [],
183
+ },
184
+ } as unknown as Instrument;
185
+
186
+ it('leaves the view unplaced, because the canvas has no size yet', () => {
187
+ const [trial] = addTrial([], [sized], 'Sized');
188
+ expect(trial?.view).toBeNull();
189
+ });
190
+ });
@@ -12,9 +12,11 @@ function findInstrument(instruments: InstrumentList, name: string): Instrument {
12
12
  }
13
13
 
14
14
  function initialView(instrument: Instrument): TrialRecord['view'] {
15
- return instrument.canvas?.initialView
16
- ? structuredClone(instrument.canvas.initialView)
17
- : { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
15
+ const declared = instrument.canvas?.initialView;
16
+ // A function needs the canvas size, which no trial has at creation. `null`
17
+ // is the unplaced marker; `Trial` resolves it on the first non-empty size.
18
+ if (typeof declared === 'function') return null;
19
+ return declared ? structuredClone(declared) : { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
18
20
  }
19
21
 
20
22
  /** Append a new trial running `instrumentName`, at that instrument's
@@ -1 +1,5 @@
1
- export { LayerStack, type LayerStackItem, type LayerStackProps } from './LayerStack';
1
+ // LayerStack lives in `@weasel-js/ui` now; labkit re-exports it so existing
2
+ // `@weasel-js/labkit/ui/layers` imports keep resolving. Named, not `export *`
3
+ // — a star re-export of an external package emits no binding in the bundle.
4
+ export type { LayerStackItem, LayerStackProps } from '@weasel-js/ui';
5
+ export { LayerStack } from '@weasel-js/ui';