@weasel-js/labkit 1.6.0 → 1.7.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 (222) hide show
  1. package/dist/_dts/{CanvasStackContext-DQgcH1ny.d.ts → CanvasStackContext-D2jnIwfk.d.ts} +1 -1
  2. package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
  3. package/dist/_dts/{LayerList.d-2c9yup5Y.d.ts → LayerList.d-DiuEArKq.d.ts} +4 -4
  4. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +79 -0
  5. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +141 -0
  6. package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Bensr_MT.d.ts} +15 -6
  7. package/dist/_dts/Stage-CCWfQQWM.d.ts +202 -0
  8. package/dist/_dts/index-B2Yj5aA0.d.ts +274 -0
  9. package/dist/_dts/{index-B0--8PEM.d.ts → index-CsYqUHD3.d.ts} +12 -13
  10. package/dist/_dts/{types-B7y8hX1D.d.ts → types-CRPSGFAp.d.ts} +33 -5
  11. package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-CSm9jgol.d.ts} +700 -650
  12. package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CTZPgeZ7.d.ts} +2 -3
  13. package/dist/canvas/index.d.ts +40 -8
  14. package/dist/canvas/index.js +5 -3
  15. package/dist/chrome/index.d.ts +5 -6
  16. package/dist/chrome/index.js +9 -7
  17. package/dist/chunk-24W5VEJW.js +18 -0
  18. package/dist/chunk-24W5VEJW.js.map +1 -0
  19. package/dist/chunk-275JRRVD.js +728 -0
  20. package/dist/chunk-275JRRVD.js.map +1 -0
  21. package/dist/chunk-3RY6WL5T.js +38 -0
  22. package/dist/chunk-3RY6WL5T.js.map +1 -0
  23. package/dist/chunk-4LJSC7MB.js +143 -0
  24. package/dist/chunk-4LJSC7MB.js.map +1 -0
  25. package/dist/{chunk-3FI7Y4R4.js → chunk-7BO7HNGF.js} +5 -5
  26. package/dist/{chunk-3FI7Y4R4.js.map → chunk-7BO7HNGF.js.map} +1 -1
  27. package/dist/chunk-AWQG7FCC.js +19 -0
  28. package/dist/chunk-AWQG7FCC.js.map +1 -0
  29. package/dist/chunk-CZZPXUOU.js +3 -0
  30. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  31. package/dist/{chunk-ZORVNOKX.js → chunk-FMK7UQ6V.js} +24 -145
  32. package/dist/chunk-FMK7UQ6V.js.map +1 -0
  33. package/dist/{chunk-BVL7Y574.js → chunk-FZ7YFQN7.js} +26 -10
  34. package/dist/chunk-FZ7YFQN7.js.map +1 -0
  35. package/dist/{chunk-USO6TERP.js → chunk-GOZLNM4L.js} +11 -9
  36. package/dist/chunk-GOZLNM4L.js.map +1 -0
  37. package/dist/{chunk-H5VIV7MQ.js → chunk-I5DUQ5HM.js} +88 -82
  38. package/dist/chunk-I5DUQ5HM.js.map +1 -0
  39. package/dist/chunk-J7OWWTCW.js +143 -0
  40. package/dist/chunk-J7OWWTCW.js.map +1 -0
  41. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  42. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  43. package/dist/{chunk-VQ47KWD5.js → chunk-KMJ7RWD3.js} +18 -7
  44. package/dist/chunk-KMJ7RWD3.js.map +1 -0
  45. package/dist/{chunk-ERFMC6WU.js → chunk-M7HP3FTK.js} +20 -11
  46. package/dist/chunk-M7HP3FTK.js.map +1 -0
  47. package/dist/chunk-MIM5R4ZT.js +14972 -0
  48. package/dist/chunk-MIM5R4ZT.js.map +1 -0
  49. package/dist/{chunk-HFRE4QQV.js → chunk-T5IBSPAM.js} +96 -32
  50. package/dist/chunk-T5IBSPAM.js.map +1 -0
  51. package/dist/chunk-UNAZSLWE.js +294 -0
  52. package/dist/chunk-UNAZSLWE.js.map +1 -0
  53. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  54. package/dist/chunk-WPLAANH4.js.map +1 -0
  55. package/dist/{chunk-AWH3SRPW.js → chunk-Y23G2VZL.js} +5 -37
  56. package/dist/chunk-Y23G2VZL.js.map +1 -0
  57. package/dist/{chunk-MOLIUNVH.js → chunk-YT75VYRD.js} +4 -4
  58. package/dist/{chunk-MOLIUNVH.js.map → chunk-YT75VYRD.js.map} +1 -1
  59. package/dist/chunk-ZIFPHIWZ.js +302 -0
  60. package/dist/chunk-ZIFPHIWZ.js.map +1 -0
  61. package/dist/config/index.d.ts +48 -40
  62. package/dist/config/index.js +4 -5
  63. package/dist/controls/index.d.ts +91 -7
  64. package/dist/controls/index.js +5 -4
  65. package/dist/dragdrop/index.d.ts +9 -7
  66. package/dist/dragdrop/index.js +3 -2
  67. package/dist/index.d.ts +294 -82
  68. package/dist/index.js +425 -402
  69. package/dist/index.js.map +1 -1
  70. package/dist/layers/index.d.ts +4 -5
  71. package/dist/layers/index.js +2 -1
  72. package/dist/loupe/index.d.ts +20 -17
  73. package/dist/loupe/index.js +5 -3
  74. package/dist/overview/index.d.ts +56 -0
  75. package/dist/overview/index.js +289 -0
  76. package/dist/overview/index.js.map +1 -0
  77. package/dist/passthrough/weasel-ui.d.ts +239 -69
  78. package/dist/passthrough/weasel-ui.js +3 -2
  79. package/dist/primitives/index.d.ts +5 -204
  80. package/dist/primitives/index.js +6 -4
  81. package/dist/state/index.d.ts +8 -8
  82. package/dist/state/index.js +4 -5
  83. package/dist/state/index.js.map +1 -1
  84. package/dist/styles.css +152 -82
  85. package/dist/undo/index.d.ts +4 -4
  86. package/package.json +17 -12
  87. package/src/annotations/AnnotationOverlay.tsx +2 -2
  88. package/src/annotations/Annotations.less +0 -10
  89. package/src/annotations/MarkList.tsx +4 -8
  90. package/src/annotations/index.ts +1 -0
  91. package/src/annotations/paint.test.ts +3 -14
  92. package/src/annotations/paint.ts +3 -19
  93. package/src/annotations/store.test.ts +26 -0
  94. package/src/annotations/store.ts +25 -6
  95. package/src/annotations/svgNodes.test.ts +6 -9
  96. package/src/annotations/svgNodes.ts +3 -3
  97. package/src/annotations/toolMap.test.ts +37 -2
  98. package/src/annotations/toolMap.ts +13 -0
  99. package/src/annotations/types.ts +27 -0
  100. package/src/canvas/AGENTS.md +9 -10
  101. package/src/canvas/CameraInput.test.tsx +136 -0
  102. package/src/canvas/CameraInput.tsx +322 -0
  103. package/src/canvas/CanvasStack.tsx +47 -58
  104. package/src/canvas/LinkedCursor.less +16 -0
  105. package/src/canvas/LinkedCursor.tsx +54 -0
  106. package/src/canvas/Stage.test.tsx +33 -4
  107. package/src/canvas/Stage.tsx +47 -37
  108. package/src/canvas/cameraRegistry.test.ts +55 -0
  109. package/src/canvas/cameraRegistry.ts +86 -0
  110. package/src/canvas/cameraView.test.ts +60 -0
  111. package/src/canvas/cameraView.ts +61 -0
  112. package/src/canvas/cameraZoom.test.ts +46 -0
  113. package/src/canvas/cameraZoom.ts +24 -0
  114. package/src/canvas/index.ts +23 -2
  115. package/src/canvas/sharedScope.test.tsx +94 -0
  116. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  117. package/src/chrome/regions/PaletteRegion.less +0 -7
  118. package/src/chrome/regions/SidebarRegion.less +12 -2
  119. package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
  120. package/src/config/builder.test.ts +2 -1
  121. package/src/config/builder.ts +15 -8
  122. package/src/config/entry.test.ts +3 -1
  123. package/src/config/fromConfigField.test.ts +14 -0
  124. package/src/config/fromConfigField.ts +19 -4
  125. package/src/config/index.ts +4 -1
  126. package/src/config/path.ts +2 -5
  127. package/src/config/resolve.ts +15 -1
  128. package/src/config/sectionTree.test.ts +12 -6
  129. package/src/config/sectionTree.ts +15 -5
  130. package/src/config/types.ts +34 -9
  131. package/src/controls/ControlPanel.less +1 -1
  132. package/src/controls/ControlPanel.stories.tsx +3 -2
  133. package/src/controls/ControlPanel.test.tsx +76 -13
  134. package/src/controls/ControlPanel.tsx +80 -11
  135. package/src/controls/inDialog.test.tsx +18 -0
  136. package/src/controls/inDialog.tsx +2 -10
  137. package/src/index.test.ts +1 -1
  138. package/src/index.ts +23 -5
  139. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  140. package/src/instrument/types.ts +10 -1
  141. package/src/lab/Lab.nebula.test.tsx +11 -2
  142. package/src/lab/Lab.test.tsx +3 -1
  143. package/src/lab/Lab.tsx +87 -76
  144. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  145. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  146. package/src/lab/LabFullChrome.stories.tsx +1 -0
  147. package/src/lab/LabHeader.test.tsx +24 -14
  148. package/src/lab/LabHeader.tsx +11 -12
  149. package/src/lab/LabShell.less +8 -17
  150. package/src/lab/LabSwitcher.less +6 -6
  151. package/src/lab/LabZoom.test.tsx +223 -0
  152. package/src/lab/LabZoom.tsx +170 -0
  153. package/src/lab/index.ts +1 -0
  154. package/src/loupe/AGENTS.md +7 -6
  155. package/src/loupe/LoupeGestures.tsx +15 -10
  156. package/src/loupe/TrialLoupe.tsx +5 -5
  157. package/src/loupe/index.ts +1 -0
  158. package/src/loupe/loupeActions.ts +6 -2
  159. package/src/overview/AGENTS.md +38 -0
  160. package/src/overview/OverviewMarks.test.tsx +100 -0
  161. package/src/overview/OverviewMarks.tsx +93 -0
  162. package/src/overview/TrialOverview.less +53 -0
  163. package/src/overview/TrialOverview.stories.tsx +50 -0
  164. package/src/overview/TrialOverview.test.tsx +116 -0
  165. package/src/overview/TrialOverview.tsx +376 -0
  166. package/src/overview/index.ts +2 -0
  167. package/src/passthrough/weasel-ui.test.ts +2 -0
  168. package/src/passthrough/weasel-ui.ts +23 -4
  169. package/src/primitives/FloatingPanel.test.tsx +11 -0
  170. package/src/primitives/FloatingPanel.tsx +2 -5
  171. package/src/primitives/JobProgress.less +1 -1
  172. package/src/primitives/Legend.stories.tsx +1 -0
  173. package/src/primitives/Readout.browser.test.tsx +137 -0
  174. package/src/primitives/Readout.less +12 -0
  175. package/src/primitives/Readout.stories.tsx +58 -0
  176. package/src/primitives/Readout.test.tsx +93 -0
  177. package/src/primitives/Readout.tsx +56 -0
  178. package/src/primitives/index.ts +2 -0
  179. package/src/specimen/Specimen.stories.tsx +1 -0
  180. package/src/specimen/Specimen.tsx +21 -4
  181. package/src/state/Persistence.browser.test.tsx +47 -0
  182. package/src/state/labRecords.test.ts +4 -1
  183. package/src/state/store.test.ts +9 -5
  184. package/src/state/usePersistedState.test.tsx +1 -1
  185. package/src/state/useTrialState.test.tsx +1 -1
  186. package/src/styles.less +3 -0
  187. package/src/theme/Interstellar.stories.tsx +4 -6
  188. package/src/theme/base.less +21 -47
  189. package/src/theme/interstellar.test.ts +1 -1
  190. package/src/theme/interstellar.theme.json +10 -3
  191. package/src/tools/labTool.ts +6 -2
  192. package/src/trial/Trial.annotations.test.tsx +16 -0
  193. package/src/trial/Trial.less +0 -13
  194. package/src/trial/Trial.loupe.test.tsx +73 -1
  195. package/src/trial/Trial.stories.tsx +2 -0
  196. package/src/trial/Trial.tsx +23 -1
  197. package/src/trial/TrialChrome.tsx +21 -3
  198. package/src/trial/trialOps.test.ts +4 -4
  199. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  200. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
  201. package/dist/_dts/index-DINmCuLA.d.ts +0 -453
  202. package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
  203. package/dist/_dts/usePanZoom-Bq73pwFe.d.ts +0 -117
  204. package/dist/chunk-2AYGEN57.js +0 -32
  205. package/dist/chunk-2AYGEN57.js.map +0 -1
  206. package/dist/chunk-AWH3SRPW.js.map +0 -1
  207. package/dist/chunk-BVL7Y574.js.map +0 -1
  208. package/dist/chunk-ERFMC6WU.js.map +0 -1
  209. package/dist/chunk-H5VIV7MQ.js.map +0 -1
  210. package/dist/chunk-HFRE4QQV.js.map +0 -1
  211. package/dist/chunk-RS3HRQWU.js.map +0 -1
  212. package/dist/chunk-SBXNQW4G.js +0 -3
  213. package/dist/chunk-TT5C5S6R.js +0 -368
  214. package/dist/chunk-TT5C5S6R.js.map +0 -1
  215. package/dist/chunk-USO6TERP.js.map +0 -1
  216. package/dist/chunk-VQ47KWD5.js.map +0 -1
  217. package/dist/chunk-WUJXJC5O.js +0 -14568
  218. package/dist/chunk-WUJXJC5O.js.map +0 -1
  219. package/dist/chunk-ZORVNOKX.js.map +0 -1
  220. package/src/canvas/usePanZoom.test.ts +0 -244
  221. package/src/canvas/usePanZoom.ts +0 -147
  222. package/src/state/Persistence.stories.tsx +0 -75
@@ -1,9 +1,9 @@
1
- export { au as UndoCapability } from '../_dts/frac-DTRq7Xpb.js';
1
+ export { az as UndoCapability } from '../_dts/types-CSm9jgol.js';
2
+ import '@weasel-js/core';
2
3
  import 'react';
3
- import '../_dts/PrefsRow.d-DFpPIeIC.js';
4
+ import '../_dts/PrefsRow.d-TOZ7BgD9.js';
4
5
  import '@weasel-js/theme';
5
- import '@weasel-js/core';
6
- import '../_dts/types-B7y8hX1D.js';
6
+ import '../_dts/types-CRPSGFAp.js';
7
7
  import '../_dts/types-DJ79Tg5J.js';
8
8
 
9
9
  /** A subscriber to a named event. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -74,6 +74,10 @@
74
74
  "types": "./dist/job/index.d.ts",
75
75
  "import": "./dist/job/index.js"
76
76
  },
77
+ "./overview": {
78
+ "types": "./dist/overview/index.d.ts",
79
+ "import": "./dist/overview/index.js"
80
+ },
77
81
  "./weasel-ui": {
78
82
  "types": "./dist/passthrough/weasel-ui.d.ts",
79
83
  "import": "./dist/passthrough/weasel-ui.js"
@@ -111,28 +115,29 @@
111
115
  "dev:3d": "LABKIT_EXAMPLE=3d-lab vite"
112
116
  },
113
117
  "peerDependencies": {
114
- "@weasel-js/core": "1.6.0",
115
- "@weasel-js/theme": "1.6.0",
118
+ "@weasel-js/core": "1.7.0",
119
+ "@weasel-js/theme": "1.7.0",
116
120
  "react": "^19.0.0",
117
121
  "react-dom": "^19.0.0"
118
122
  },
119
123
  "dependencies": {
120
- "@weasel-js/geom": "1.6.0",
121
- "@weasel-js/kernel3d": "1.6.0",
122
- "@weasel-js/loupe": "1.6.0",
123
- "@weasel-js/svg": "1.6.0",
124
- "@weasel-js/ui": "1.6.0",
125
- "earcut": "2.2.4",
124
+ "@weasel-js/geom": "1.7.0",
125
+ "@weasel-js/kernel3d": "1.7.0",
126
+ "@weasel-js/loupe": "1.7.0",
127
+ "@weasel-js/quantity": "1.7.0",
128
+ "@weasel-js/svg": "1.7.0",
129
+ "@weasel-js/ui": "1.7.0",
130
+ "earcut": "3.2.3",
126
131
  "polygon-clipping": "^0.15.7",
127
132
  "react-aria-components": "^1.5.0",
128
- "windease": "^1.3.0",
133
+ "windease": "^2.0.0",
129
134
  "zustand": "^5.0.12"
130
135
  },
131
136
  "devDependencies": {
132
137
  "@biomejs/biome": "^2.4.12",
133
138
  "@rollup/plugin-alias": "^6.0.0",
134
- "@testing-library/jest-dom": "^6.6.0",
135
- "@weasel-js/modes": "1.6.0",
139
+ "@testing-library/jest-dom": "^7.0.1",
140
+ "@weasel-js/modes": "1.7.0",
136
141
  "less": "^4.2.0",
137
142
  "rollup-plugin-dts": "^6.4.1",
138
143
  "tsx": "^4.19.0",
@@ -236,11 +236,11 @@ export function AnnotationOverlay({
236
236
  style={box}
237
237
  />
238
238
  {canvas && input ? (
239
- // One scope per pane: a shared <ActionsProvider> lets only the newest
240
- // canvas under it respond to input, and the rest go silently dead.
239
+ // Isolated so each pane keeps its own selection and tool.
241
240
  <WeaselProvider isolate>
242
241
  <ToolBridge toolId={annotationToolInfo(activeToolId)?.weaselTool ?? 'select'} />
243
242
  <SceneCanvas<AnnotationData, 'marks', WorldRect>
243
+ features={['pick', 'move', 'transform', 'edit']}
244
244
  ref={attachSceneCanvas}
245
245
  scene={scene}
246
246
  width={rect.w}
@@ -59,16 +59,6 @@
59
59
 
60
60
  .lk-mark-list__drop {
61
61
  margin-left: auto;
62
- background: none;
63
- border: 0;
64
- color: var(--wzl-fg-muted);
65
- cursor: pointer;
66
- }
67
-
68
- // Box and typography come from <Input>. This only says how the field shares
69
- // the row.
70
- .lk-mark-list__title {
71
- width: 100%;
72
62
  }
73
63
 
74
64
  // The export panel: a target to export, how, and at what resolution. Held in
@@ -1,4 +1,4 @@
1
- import { Input } from '../passthrough/weasel-ui';
1
+ import { CloseButton, Input } from '../passthrough/weasel-ui';
2
2
  import { useAnnotations } from './AnnotationsContext';
3
3
  import type { AnnotationMeaning } from './types';
4
4
 
@@ -35,17 +35,13 @@ export function MarkList({ meaning, config }: MarkListProps) {
35
35
  <span className="lk-mark-list__kind">{a.kind}</span>
36
36
  <span className="lk-mark-list__target">{a.target}</span>
37
37
  {stale ? <span className="lk-mark-list__stale">stale</span> : null}
38
- <button
39
- type="button"
38
+ <CloseButton
40
39
  className="lk-mark-list__drop"
41
- aria-label={`Remove ${a.kind} on ${a.target}`}
40
+ ariaLabel={`Remove ${a.kind} on ${a.target}`}
42
41
  onClick={() => marks.remove(a.id)}
43
- >
44
- ×
45
- </button>
42
+ />
46
43
  </div>
47
44
  <Input
48
- className="lk-mark-list__title"
49
45
  aria-label={`Title of ${a.kind} on ${a.target}`}
50
46
  value={a.title ?? ''}
51
47
  placeholder="Untitled"
@@ -28,6 +28,7 @@ export type {
28
28
  AnnotationsCapability,
29
29
  AnnotationTarget,
30
30
  AnnotationTargetInfo,
31
+ AnnotationToolId,
31
32
  CaptureOptions,
32
33
  CaptureResult,
33
34
  CaptureSource,
@@ -48,23 +48,12 @@ describe('markCommands', () => {
48
48
  ]);
49
49
  });
50
50
 
51
- it('makes an arrow the same line, plus the head as its own command', () => {
51
+ it('makes an arrow the same line, carrying an end marker', () => {
52
52
  const cmds = markCommands(mark('arrow', { points: ENDS }), CONTENT);
53
+ expect(cmds).toHaveLength(1);
53
54
  const stroke = (cmds[0] as { stroke?: { markerEnd?: unknown } }).stroke;
54
55
  expect(stroke?.markerEnd).toBe('arrow');
55
- expect(anchors([cmds[0]])).toEqual([0.1 * 200, 0.2 * 100, 0.6 * 200, 0.9 * 100]);
56
-
57
- // The head has to be geometry, not just a field: `markerEnd` alone is
58
- // inert outside the kit's own node painter, which is what shipped an
59
- // arrow with no head.
60
- expect(cmds).toHaveLength(2);
61
- const head = anchors([cmds[1]]);
62
- expect(head.length).toBeGreaterThan(0);
63
- // Its vertex is the line's far end — computed, not eyeballed.
64
- const near = (v: number, t: number) => Math.abs(v - t) < 1;
65
- const xs = head.filter((_, i) => i % 2 === 0);
66
- const ys = head.filter((_, i) => i % 2 === 1);
67
- expect(xs.some((x, i) => near(x, 120) && near(ys[i] as number, 90))).toBe(true);
56
+ expect(anchors(cmds)).toEqual([0.1 * 200, 0.2 * 100, 0.6 * 200, 0.9 * 100]);
68
57
  });
69
58
 
70
59
  it('threads a freehand stroke through every point', () => {
@@ -1,20 +1,12 @@
1
1
  import type { DrawCommand, Path, Stroke } from '@weasel-js/core';
2
- import {
3
- ellipsePath,
4
- linePath,
5
- markerDrawCommands,
6
- PathBuilder,
7
- rectPath,
8
- textCommand,
9
- } from '@weasel-js/core';
2
+ import { ellipsePath, linePath, PathBuilder, rectPath, textCommand } from '@weasel-js/core';
10
3
  import type { WorldRect } from './frac';
11
4
  import type { AnnotationData, FracPoint } from './types';
12
5
 
13
6
  /** One loud color, not a themed one: a mark sits over the instrument's own
14
7
  * picture and has to be legible against whatever that picture is. */
15
8
  const MARK_COLOR = '#e5484d';
16
- /** World units, so a mark thickens with the picture it annotates — and so the
17
- * marker geometry can be resolved here, which needs a resolved width. */
9
+ /** World units, so a mark thickens with the picture it annotates. */
18
10
  const MARK_WIDTH = 2;
19
11
  const TEXT_SIZE = 14;
20
12
  const POINT_RADIUS = 4;
@@ -97,15 +89,7 @@ export function markCommands(
97
89
  case 'arrow': {
98
90
  const [a, b] = vertices(m, content);
99
91
  // The spec's arrow: a line carrying an end marker, not its own geometry.
100
- // Markers are separate commands — a stroke's `markerEnd` is inert unless
101
- // something turns it into geometry, and only the kit's own node painter
102
- // does that, for the nodes it owns.
103
- const marked: Stroke = { ...stroke, markerEnd: 'arrow' };
104
- const path = linePath(a, b);
105
- return [
106
- { kind: 'path', path, stroke: marked },
107
- ...markerDrawCommands(path, marked, MARK_WIDTH, undefined),
108
- ];
92
+ return [{ kind: 'path', path: linePath(a, b), stroke: { ...stroke, markerEnd: 'arrow' } }];
109
93
  }
110
94
  case 'stroke':
111
95
  return [{ kind: 'path', path: polyline(vertices(m, content)), stroke }];
@@ -294,3 +294,29 @@ describe('the annotation store', () => {
294
294
  expect(svg).toContain('#222222');
295
295
  });
296
296
  });
297
+
298
+ describe('paintedMarks', () => {
299
+ it("hands over a target's marks in world units with their resolved style", () => {
300
+ let config: Record<string, string> = { angle: 'iso', shading: 'outline' };
301
+ const store = createAnnotationStore({
302
+ targets: () => TARGETS,
303
+ meaning: { statuses: [{ id: 'open', label: 'Open', color: '#123456' }] },
304
+ config: () => config,
305
+ });
306
+ store.add(RING, config);
307
+ const [painted] = store.paintedMarks('naive');
308
+ expect(painted?.mark.data.kind).toBe('rect');
309
+ expect(painted?.mark.pose.x).toBeCloseTo(0.3322 * 256);
310
+ expect(painted?.style).toEqual({ color: '#123456', stale: false });
311
+
312
+ // A config the mark no longer describes draws it dashed, as the pane does.
313
+ config = { angle: 'top', shading: 'outline' };
314
+ expect(store.paintedMarks('naive')[0]?.style.stale).toBe(true);
315
+ });
316
+
317
+ it('answers nothing for a target with no marks or no declaration', () => {
318
+ const store = makeStore();
319
+ expect(store.paintedMarks('occt')).toEqual([]);
320
+ expect(store.paintedMarks('nowhere')).toEqual([]);
321
+ });
322
+ });
@@ -6,6 +6,7 @@ import {
6
6
  sceneFromJSON,
7
7
  } from '@weasel-js/core';
8
8
  import { captureTarget } from './capture';
9
+ import { type MarkDrawOptions, resolveMarkStyle } from './drawOne';
9
10
  import type { WorldRect } from './frac';
10
11
  import { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
11
12
  import { MarkHistory } from './history';
@@ -123,6 +124,14 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
123
124
  targets().find((t) => t.id === id);
124
125
 
125
126
  const contentOf = (id: string) => targetOf(id)?.content ?? NO_CONTENT;
127
+ /** How a target's marks are drawn right now — one answer for the pane, an
128
+ * export and an overview, so none can disagree on a color or a dash. */
129
+ const drawOptionsFor = (info: AnnotationTargetInfo): MarkDrawOptions => ({
130
+ content: info.content,
131
+ positionDependsOn: info.positionDependsOn,
132
+ config: opts.config?.(),
133
+ meaning: typeof opts.meaning === 'function' ? opts.meaning() : opts.meaning,
134
+ });
126
135
 
127
136
  const project = (target: string, node: string): Annotation | undefined => {
128
137
  const found = scenes.get(target)?.get(asNodeId(node));
@@ -304,6 +313,21 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
304
313
  if (found) found.scene.remove(asNodeId(found.node.id));
305
314
  },
306
315
 
316
+ paintedMarks(target) {
317
+ const info = targetOf(target);
318
+ const scene = scenes.get(target);
319
+ if (!info || !scene) return [];
320
+ const draw = drawOptionsFor(info);
321
+ return [...scene.renderOrder()].flatMap((id) => {
322
+ const node = scene.get(asNodeId(String(id)));
323
+ if (!node) return [];
324
+ const data = node.data as AnnotationData;
325
+ return [
326
+ { mark: { pose: node.pose as MarkPose, data }, style: resolveMarkStyle(data, draw) },
327
+ ];
328
+ });
329
+ },
330
+
307
331
  async capture(target: string, captureOpts?: CaptureOptions) {
308
332
  const info = targetOf(target);
309
333
  if (!info) throw new Error(`[labkit] no annotation target called '${target}'`);
@@ -311,12 +335,7 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
311
335
  {
312
336
  target,
313
337
  scene: sceneFor(target),
314
- draw: {
315
- content: info.content,
316
- positionDependsOn: info.positionDependsOn,
317
- config: opts.config?.(),
318
- meaning: typeof opts.meaning === 'function' ? opts.meaning() : opts.meaning,
319
- },
338
+ draw: drawOptionsFor(info),
320
339
  base: info.base,
321
340
  onWarn: (message) => console.warn(`[labkit] capture: ${message}`),
322
341
  },
@@ -51,16 +51,13 @@ describe('a mark as vector', () => {
51
51
  expect(node.stroke?.dash).toEqual([6, 4]);
52
52
  });
53
53
 
54
- it("drops the arrow's marker reference, because its head is already a node", () => {
54
+ it("keeps the arrow's marker, so the export draws its head", () => {
55
55
  const nodes = markSvgNodes(mark('arrow'), CONTENT);
56
- expect(nodes.length).toBeGreaterThan(1);
57
- for (const node of nodes) {
58
- if (node.kind !== 'path') throw new Error('expected path nodes');
59
- // Keeping `markerEnd` would make the serializer emit a <marker> def as
60
- // well, and the head would be drawn twice.
61
- expect(node.stroke?.markerEnd).toBeUndefined();
62
- }
63
- expect(serializeSvg(nodes, { viewBox: { x: 0, y: 0, width: 100, height: 60 } })).not.toContain(
56
+ expect(nodes).toHaveLength(1);
57
+ const [node] = nodes;
58
+ if (node?.kind !== 'path') throw new Error('expected a path node');
59
+ expect(node.stroke?.markerEnd).toBe('arrow');
60
+ expect(serializeSvg(nodes, { viewBox: { x: 0, y: 0, width: 100, height: 60 } })).toContain(
64
61
  '<marker',
65
62
  );
66
63
  });
@@ -16,9 +16,6 @@ function toSvgPaint(paint: FillStyle | undefined): SvgPaint {
16
16
  return { kind: 'gradient', paint };
17
17
  }
18
18
 
19
- /** Marker references are deliberately dropped: `markCommands` already resolves
20
- * a marker into its own path command, and keeping the reference would make
21
- * the serializer emit a `<marker>` def that draws the head a second time. */
22
19
  function toSvgStroke(stroke: Stroke): SvgStroke {
23
20
  return {
24
21
  paint: toSvgPaint(stroke.paint),
@@ -28,6 +25,9 @@ function toSvgStroke(stroke: Stroke): SvgStroke {
28
25
  ...(stroke.dash ? { dash: [...stroke.dash] } : {}),
29
26
  ...(stroke.miterLimit != null ? { miterLimit: stroke.miterLimit } : {}),
30
27
  ...(stroke.align ? { align: stroke.align } : {}),
28
+ ...(stroke.markerStart ? { markerStart: stroke.markerStart } : {}),
29
+ ...(stroke.markerMid ? { markerMid: stroke.markerMid } : {}),
30
+ ...(stroke.markerEnd ? { markerEnd: stroke.markerEnd } : {}),
31
31
  };
32
32
  }
33
33
 
@@ -1,7 +1,9 @@
1
1
  import { KIT_SHAPE_KINDS } from '@weasel-js/core';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { ANNOTATION_TOOLS, annotationToolInfo } from './toolMap';
4
- import type { AnnotationKind } from './types';
3
+ import type { InstrumentList } from '../instrument/types';
4
+ import { resolveLabTool } from '../tools/labTool';
5
+ import { ANNOTATION_TOOLS, annotationToolInfo, labAnnotationTools } from './toolMap';
6
+ import type { AnnotationKind, AnnotationToolId } from './types';
5
7
 
6
8
  const KINDS: AnnotationKind[] = ['stroke', 'line', 'arrow', 'rect', 'ellipse', 'text'];
7
9
 
@@ -38,3 +40,36 @@ describe('the annotation tool table', () => {
38
40
  expect(annotationToolInfo('hand')).toBeUndefined();
39
41
  });
40
42
  });
43
+
44
+ describe('the tools a lab rail carries', () => {
45
+ const ids = (instruments: InstrumentList) => labAnnotationTools(instruments).map((t) => t.id);
46
+ const inst = (annotations?: { tools?: AnnotationToolId[] }) =>
47
+ ({
48
+ annotations: annotations && { targets: () => [], ...annotations },
49
+ }) as unknown as InstrumentList[number];
50
+
51
+ it('carries none when no instrument annotates', () => {
52
+ expect(ids([inst()])).toEqual([]);
53
+ });
54
+
55
+ it('carries every tool for an instrument that names none', () => {
56
+ expect(ids([inst({})])).toEqual(ANNOTATION_TOOLS.map((t) => t.id));
57
+ });
58
+
59
+ it('carries only the named tools, in the kit order', () => {
60
+ expect(ids([inst({ tools: ['select', 'pointer'] })])).toEqual(['pointer', 'select']);
61
+ });
62
+
63
+ it('carries the union across annotating instruments', () => {
64
+ expect(ids([inst({ tools: ['pointer'] }), inst({ tools: ['rect'] }), inst()])).toEqual([
65
+ 'pointer',
66
+ 'rect',
67
+ ]);
68
+ });
69
+
70
+ it('starts the lab in pointer when the rail has it, else in its first tool', () => {
71
+ expect(resolveLabTool(null, [inst({ tools: ['select', 'pointer'] })])).toBe('pointer');
72
+ expect(resolveLabTool(null, [inst({ tools: ['text', 'select'] })])).toBe('select');
73
+ expect(resolveLabTool(null, [inst()])).toBeNull();
74
+ });
75
+ });
@@ -9,6 +9,7 @@ import {
9
9
  SelectIcon,
10
10
  TextIcon,
11
11
  } from '@weasel-js/ui';
12
+ import type { InstrumentList } from '../instrument/types';
12
13
  import type { TrialTool } from '../tools/types';
13
14
  import type { AnnotationKind } from './types';
14
15
 
@@ -46,6 +47,18 @@ export const ANNOTATION_TOOLS: readonly TrialTool[] = [
46
47
  { id: 'text', label: 'Text', icon: TextIcon, group: 'annotate' },
47
48
  ];
48
49
 
50
+ /** The annotation tools a lab's rail carries for these instruments: none
51
+ * without an annotating instrument, else the union of what each asks for. */
52
+ export function labAnnotationTools(instruments: InstrumentList): readonly TrialTool[] {
53
+ const wanted = new Set<string>();
54
+ for (const i of instruments) {
55
+ if (i.annotations == null) continue;
56
+ if (i.annotations.tools == null) return ANNOTATION_TOOLS;
57
+ for (const id of i.annotations.tools) wanted.add(id);
58
+ }
59
+ return ANNOTATION_TOOLS.filter((t) => wanted.has(t.id));
60
+ }
61
+
49
62
  /** What `id` drives, or undefined for a tool that is not one of these. */
50
63
  export function annotationToolInfo(id: string | null): AnnotationToolInfo | undefined {
51
64
  return id === null ? undefined : TOOLS[id];
@@ -1,6 +1,7 @@
1
1
  import type { RefObject } from 'react';
2
2
  import type { ViewTransform } from '../instrument/types';
3
3
  import type { TrialInfo } from '../state/types';
4
+ import type { MarkStyle, PaintableMark } from './paint';
4
5
  import type { MarkScene } from './store';
5
6
 
6
7
  /** A point in fractions of a target's content box. */
@@ -160,10 +161,25 @@ export interface AnnotationStorage {
160
161
 
161
162
  /** Declares that an instrument accepts marks: which regions take them,
162
163
  * optionally what a mark is allowed to mean, and optionally where they live. */
164
+ /** The annotation tools a lab's rail can carry. */
165
+ export type AnnotationToolId =
166
+ | 'pointer'
167
+ | 'select'
168
+ | 'stroke'
169
+ | 'line'
170
+ | 'arrow'
171
+ | 'rect'
172
+ | 'ellipse'
173
+ | 'text';
174
+
163
175
  export interface AnnotationsCapability<TS = unknown, TC = unknown> {
164
176
  /** `trial` is which trial is asking: a declaration made once per instrument
165
177
  * is called once per trial, and its targets are that trial's own. */
166
178
  targets: (state: TS, config: TC, trial: TrialInfo) => readonly AnnotationTarget[];
179
+ /** Which annotation tools the lab's rail offers. The rail is shared, so it
180
+ * carries every tool any annotating instrument asks for, in the kit's own
181
+ * order; an instrument that leaves this unset asks for all of them. */
182
+ tools?: readonly AnnotationToolId[];
167
183
  meaning?: AnnotationMeaning;
168
184
  /** An instrument replacing this one under a live trial must pass the same
169
185
  * object: the trial keeps the marks it loaded from the first. */
@@ -183,6 +199,12 @@ export interface SerializedAnnotations {
183
199
  scenes: Record<string, unknown>;
184
200
  }
185
201
 
202
+ /** One mark, as `AnnotationsApi.paintedMarks` hands it over. */
203
+ export interface PaintedMark {
204
+ mark: PaintableMark;
205
+ style: MarkStyle;
206
+ }
207
+
186
208
  /** Everything a host can ask or tell labkit about the marks on its targets. */
187
209
  export interface AnnotationsApi {
188
210
  /** The scene a target's marks live in, created on first ask. One per target
@@ -222,6 +244,11 @@ export interface AnnotationsApi {
222
244
  undo(): boolean;
223
245
  redo(): boolean;
224
246
 
247
+ /** A target's marks as its pane paints them, in scene order: geometry in
248
+ * the target's content box, each with the style its status and staleness
249
+ * resolve to. For a painter outside the pane — an overview. */
250
+ paintedMarks(target: string): PaintedMark[];
251
+
225
252
  /** Export a target's picture with its marks on it. Rejects on an id the
226
253
  * instrument does not declare. */
227
254
  capture(target: string, opts?: CaptureOptions): Promise<CaptureResult>;
@@ -6,18 +6,17 @@ For a renderer labkit does not own — three.js, raw WebGL, anything with its ow
6
6
  render loop — see `src/surface/AGENTS.md` instead. `CanvasStack` is 2D and
7
7
  schedules its own layers; a foreign renderer wants rects and dirtiness only.
8
8
 
9
- `useOrbit` also lives here, as the 3D peer of `usePanZoom`. It produces a trial
10
- view (`{ yaw, pitch, distance, target }`) rather than a matrix — turning that
11
- into a camera is the host's job.
12
-
13
9
  ## Files
14
10
 
15
11
  | File | Role |
16
12
  |---|---|
17
- | `CanvasStack.tsx` | Layered `<canvas>` container; owns sizing, pan/zoom handlers, and per-layer canvas refs |
13
+ | `CanvasStack.tsx` | Layered `<canvas>` container; owns sizing and per-layer canvas refs |
18
14
  | `CanvasStackContext.ts` | React context exposing the current `view` to descendants |
19
15
  | `useLayerScheduler.ts` | DPR-aware rAF scheduler; redraws dirty layers on view/state changes |
20
- | `usePanZoom.ts` | Pointer + wheel handlers that mutate `view` via `onViewChange` |
16
+ | `CameraInput.tsx` | Pan, zoom and tap routed through weasel's gesture dispatcher; `CameraContext`; publishes the pointer |
17
+ | `cameraRegistry.ts` | Cameras by trial id, so chrome outside a trial (the lab header's zoom) can drive the focused one |
18
+ | `cameraView.ts` | The camera as a weasel `View` over frame-local coordinates, and the anchored zoom clamp |
19
+ | `LinkedCursor.tsx` | The crosshair on the camera's element while the pointer is over another view |
21
20
  | `camera.ts` | `zoomAt` (fixed-point zoom) and `centerOn` (put a world point at a viewport's middle) |
22
21
  | `canvasCoords.ts` | Pure `screenToWorld` / `worldToScreen` helpers |
23
22
  | `worldSpec.ts` | The instrument's declared coordinate system, and the camera derived from it |
@@ -32,8 +31,8 @@ interface CanvasStackProps {
32
31
  onViewChange: (v: ViewTransform) => void;
33
32
  worldSpec?: WorldSpec; // origin + y direction; default top-left, y down
34
33
  onResize?: (size: ViewportSize) => void;
35
- minZoom?: number; // default 0.1, forwarded to usePanZoom
36
- maxZoom?: number; // default 32, forwarded to usePanZoom
34
+ minZoom?: number; // default 0.1; the opening zoom stays reachable
35
+ maxZoom?: number; // default 32
37
36
  width?: number | string; // default '100%'
38
37
  height?: number | string; // default '100%'
39
38
  className?: string;
@@ -93,11 +92,11 @@ convention: origin at the element's top-left, y downward.
93
92
  `resolveFrame(spec, size)` turns a spec plus a measured viewport into a
94
93
  `WorldFrame` — `{ originPx, yDir }` — which is what the rest of the directory
95
94
  actually consumes. `CanvasStack` owns the measurement, so it owns the frame and
96
- hands it to `usePanZoom`, to the scheduler, and to every `render`.
95
+ hands it to the camera (`useCameraView`), to the scheduler, and to every `render`.
97
96
 
98
97
  **Every world↔screen path must go through the frame.** There are four, and each
99
98
  one is a silent wrong answer if it is missed: `worldToScreen` / `screenToWorld`,
100
- the camera (`applyCamera`), the wheel anchor in `usePanZoom`, and
99
+ the camera (`applyCamera`), the camera as a weasel `View` (`cameraView.ts`), and
101
100
  `DragDropRuntime`'s drop position. A miss produces geometry that is off by a
102
101
  constant, or a wheel that drifts — never an error.
103
102