@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
@@ -0,0 +1,223 @@
1
+ import { act, fireEvent, render, screen, within } from '@testing-library/react';
2
+ import {
3
+ ActionsProvider,
4
+ type ActionsRegistry,
5
+ ActiveToolContextProvider,
6
+ DepRegistryProvider,
7
+ type Tool,
8
+ useActionsRegistry,
9
+ useGestureDispatcher,
10
+ } from '@weasel-js/core';
11
+ import { type ReactNode, useEffect } from 'react';
12
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
13
+ import { defineInstrument } from '../instrument/defineInstrument';
14
+ import { Lab } from './Lab';
15
+ import { LabContext, type LabContextValue } from './LabContext';
16
+
17
+ beforeAll(() => {
18
+ HTMLCanvasElement.prototype.getContext = vi.fn(
19
+ () => null,
20
+ ) as unknown as HTMLCanvasElement['getContext'];
21
+ });
22
+
23
+ const staged = defineInstrument<Record<string, never>, Record<string, never>>({
24
+ name: 'Staged',
25
+ defaultConfig: () => ({}),
26
+ initialState: () => ({}),
27
+ render: () => <div data-testid="art" />,
28
+ stage: {
29
+ size: { width: 240, height: 160 },
30
+ initialView: { zoom: 1, pan: { x: 0, y: 0 } },
31
+ maxZoom: 1.5,
32
+ },
33
+ });
34
+
35
+ const plain = defineInstrument<Record<string, never>, Record<string, never>>({
36
+ name: 'Plain',
37
+ defaultConfig: () => ({}),
38
+ initialState: () => ({}),
39
+ render: () => <div data-testid="plain" />,
40
+ });
41
+
42
+ let lab: LabContextValue | null = null;
43
+ function Capture({ children }: { children?: ReactNode }) {
44
+ return (
45
+ <LabContext.Consumer>
46
+ {(value) => {
47
+ lab = value;
48
+ return children ?? null;
49
+ }}
50
+ </LabContext.Consumer>
51
+ );
52
+ }
53
+
54
+ const zoomBar = () => within(screen.getByRole('toolbar', { name: 'Zoom' }));
55
+ const readout = () => zoomBar().getByRole('button', { name: /^Zoom \d+%|^Zoom unavailable/ });
56
+ const press = (name: string) =>
57
+ act(() => {
58
+ fireEvent.click(zoomBar().getByRole('button', { name }));
59
+ });
60
+
61
+ const MOD = /mac/i.test(navigator.platform || navigator.userAgent)
62
+ ? { metaKey: true }
63
+ : { ctrlKey: true };
64
+ function key(k: string): boolean {
65
+ let prevented = false;
66
+ act(() => {
67
+ prevented = !fireEvent.keyDown(window, { key: k, ...MOD });
68
+ });
69
+ return prevented;
70
+ }
71
+
72
+ const NO_TOOLS: ReadonlyMap<string, Tool> = new Map();
73
+
74
+ /** A story's own dispatcher, binding Mod+= the way a `<SceneCanvas>` with keyboard zoom does. */
75
+ function StoryZoomKey({ run }: { run: () => void }) {
76
+ function Bound() {
77
+ const registry = useActionsRegistry() as ActionsRegistry;
78
+ useEffect(
79
+ () =>
80
+ registry.register({
81
+ id: 'story.zoom',
82
+ label: 'Story zoom',
83
+ defaultBinding: { kind: 'key', key: '=', mods: { mod: true } },
84
+ invoker: { timing: 'immediate', run: () => run() },
85
+ }),
86
+ [registry],
87
+ );
88
+ useGestureDispatcher({
89
+ canvasRef: { current: null },
90
+ actions: registry,
91
+ entriesById: NO_TOOLS,
92
+ channels: { contextMenu: false, ingest: false },
93
+ });
94
+ return null;
95
+ }
96
+ return (
97
+ <DepRegistryProvider>
98
+ <ActionsProvider>
99
+ <ActiveToolContextProvider>
100
+ <Bound />
101
+ </ActiveToolContextProvider>
102
+ </ActionsProvider>
103
+ </DepRegistryProvider>
104
+ );
105
+ }
106
+
107
+ function stageZoom(container: HTMLElement, index = 0): number {
108
+ const el = container.querySelectorAll<HTMLElement>('.lk-stage__content')[index];
109
+ if (!el) throw new Error('no stage content');
110
+ return Number(el.style.getPropertyValue('--lk-stage-zoom'));
111
+ }
112
+
113
+ describe("the lab header's zoom controls", () => {
114
+ it('step the focused trial through its camera and read its zoom back', () => {
115
+ const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
116
+ expect(readout()).toHaveTextContent('100%');
117
+ press('Zoom in');
118
+ expect(stageZoom(container)).toBeCloseTo(1.25);
119
+ expect(readout()).toHaveTextContent('125%');
120
+ press('Zoom out');
121
+ expect(stageZoom(container)).toBeCloseTo(1);
122
+ press('Zoom out');
123
+ expect(readout()).toHaveTextContent('80%');
124
+ });
125
+
126
+ it('reset to actual size from the readout', () => {
127
+ const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
128
+ press('Zoom out');
129
+ press('Zoom out');
130
+ act(() => {
131
+ fireEvent.click(readout());
132
+ });
133
+ expect(stageZoom(container)).toBe(1);
134
+ expect(readout()).toHaveTextContent('100%');
135
+ });
136
+
137
+ it("stop at the camera's own limit, and say so", () => {
138
+ const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
139
+ press('Zoom in');
140
+ press('Zoom in');
141
+ expect(stageZoom(container)).toBe(1.5);
142
+ expect(zoomBar().getByRole('button', { name: 'Zoom in' })).toBeDisabled();
143
+ expect(zoomBar().getByRole('button', { name: 'Zoom out' })).toBeEnabled();
144
+ });
145
+
146
+ it('answer Mod+=, Mod+- and Mod+0, claiming the key from the browser', () => {
147
+ const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
148
+ expect(key('=')).toBe(true);
149
+ expect(stageZoom(container)).toBeCloseTo(1.25);
150
+ key('-');
151
+ key('-');
152
+ expect(stageZoom(container)).toBeCloseTo(0.8);
153
+ key('0');
154
+ expect(stageZoom(container)).toBe(1);
155
+ });
156
+
157
+ it('go inert over a trial with no camera, and leave its keys to the browser', () => {
158
+ render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
159
+ for (const name of ['Zoom out', 'Zoom in']) {
160
+ expect(zoomBar().getByRole('button', { name })).toBeDisabled();
161
+ }
162
+ expect(readout()).toBeDisabled();
163
+ expect(readout()).toHaveTextContent('–');
164
+ expect(key('=')).toBe(false);
165
+ });
166
+
167
+ it("take the key ahead of a story's own binding in a trial with a camera", () => {
168
+ const story = vi.fn();
169
+ const zooming = defineInstrument<Record<string, never>, Record<string, never>>({
170
+ ...staged,
171
+ name: 'Zooming',
172
+ render: () => <StoryZoomKey run={story} />,
173
+ });
174
+ const { container } = render(<Lab instruments={[zooming]} defaultInstrument="Zooming" />);
175
+ key('=');
176
+ expect(stageZoom(container)).toBeCloseTo(1.25);
177
+ expect(story).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it("leave the key to a story's own binding in a trial with no camera", () => {
181
+ const story = vi.fn();
182
+ const zooming = defineInstrument<Record<string, never>, Record<string, never>>({
183
+ ...plain,
184
+ name: 'ZoomingPlain',
185
+ render: () => <StoryZoomKey run={story} />,
186
+ });
187
+ render(<Lab instruments={[zooming]} defaultInstrument="ZoomingPlain" />);
188
+ key('=');
189
+ expect(story).toHaveBeenCalledTimes(1);
190
+ });
191
+
192
+ it('follow the focus from trial to trial', () => {
193
+ const { container } = render(
194
+ <Lab instruments={[staged, plain]} defaultInstrument="Staged">
195
+ <Capture />
196
+ </Lab>,
197
+ );
198
+ act(() => lab?.addTrial('Staged'));
199
+ act(() => lab?.addTrial('Plain'));
200
+ const [first, second, third] = lab?.trials.map((t) => t.id) ?? [];
201
+ expect(zoomBar().getByRole('button', { name: 'Zoom in' })).toBeDisabled();
202
+
203
+ act(() => lab?.focusTrial(second ?? ''));
204
+ press('Zoom out');
205
+ expect(stageZoom(container, 1)).toBeCloseTo(0.8);
206
+ expect(stageZoom(container, 0)).toBe(1);
207
+ expect(readout()).toHaveTextContent('80%');
208
+
209
+ act(() => lab?.focusTrial(first ?? ''));
210
+ expect(readout()).toHaveTextContent('100%');
211
+ key('=');
212
+ expect(stageZoom(container, 0)).toBeCloseTo(1.25);
213
+ expect(stageZoom(container, 1)).toBeCloseTo(0.8);
214
+
215
+ act(() => lab?.focusTrial(third ?? ''));
216
+ expect(readout()).toBeDisabled();
217
+ });
218
+
219
+ it('are left out when the lab says so', () => {
220
+ render(<Lab instruments={[staged]} defaultInstrument="Staged" zoom={false} />);
221
+ expect(screen.queryByRole('toolbar', { name: 'Zoom' })).toBeNull();
222
+ });
223
+ });
@@ -0,0 +1,170 @@
1
+ import {
2
+ type Action,
3
+ ActionDisabledReason,
4
+ ActionsProvider,
5
+ type ActionsRegistry,
6
+ ActiveToolContextProvider,
7
+ DepRegistryProvider,
8
+ makeViewportZoomAction,
9
+ type Tool,
10
+ useActionsRegistry,
11
+ useDepSource,
12
+ useGestureDispatcher,
13
+ type ViewApi,
14
+ } from '@weasel-js/core';
15
+ import { ZoomInIcon, ZoomOutIcon } from '@weasel-js/ui';
16
+ import { useContext, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
17
+ import { useStore } from 'zustand/react';
18
+ import type { CameraView } from '../canvas/CameraInput';
19
+ import { CameraRegistryContext } from '../canvas/cameraRegistry';
20
+ import { atScale, viewMiddle } from '../canvas/cameraZoom';
21
+ import { formatShortcut } from '../passthrough/weasel-ui';
22
+ import { Toolbar } from '../primitives/Toolbar';
23
+ import { LabStoreContext } from '../state/context';
24
+ import { as2DView } from '../state/view';
25
+ import { useLabContext } from './LabContext';
26
+
27
+ const ZOOM_ID = 'viewport.zoom';
28
+ const NO_TOOLS: ReadonlyMap<string, Tool> = new Map();
29
+ const CHANNELS = { contextMenu: false, ingest: false } as const;
30
+ const NO_ELEMENT = { current: null };
31
+
32
+ type ZoomKind = 'in' | 'out' | 'reset';
33
+
34
+ interface KeyBinding {
35
+ spec: { kind: string; key?: string; mods?: { mod?: boolean; shift?: boolean | 'optional' } };
36
+ opts?: { params?: { kind?: string } };
37
+ }
38
+
39
+ // Core's zoom with only its keys: the wheel and pinch belong to each trial's
40
+ // own dispatcher. The clamp is left to the camera, which knows its range.
41
+ const BASE = makeViewportZoomAction({ min: 1e-9, max: Number.POSITIVE_INFINITY });
42
+ const KEY_BINDINGS = (BASE.defaultBinding as KeyBinding[]).filter((b) => b.spec.kind === 'key');
43
+
44
+ function shortcutFor(kind: ZoomKind): string | undefined {
45
+ const spec = KEY_BINDINGS.find((b) => b.opts?.params?.kind === kind)?.spec;
46
+ return spec?.key ? formatShortcut({ key: spec.key, mod: spec.mods?.mod }) : undefined;
47
+ }
48
+
49
+ /** The focused trial's camera, with the zoom's reset meaning actual size about
50
+ * the middle of the view. */
51
+ function forZoom(camera: CameraView): ViewApi {
52
+ return {
53
+ get: camera.get,
54
+ set: camera.set,
55
+ hostSize: camera.hostSize,
56
+ recenter: () => atScale(camera.get(), 1, viewMiddle(camera)),
57
+ };
58
+ }
59
+
60
+ const percent = new Intl.NumberFormat(undefined, { style: 'percent', maximumFractionDigits: 0 });
61
+
62
+ /**
63
+ * Zoom out, the zoom as a percentage, and zoom in, for whichever trial has the
64
+ * focus. The percentage resets to actual size. They route through core's
65
+ * `viewport.zoom` action, on a dispatcher of their own that listens for its
66
+ * keys — Mod+=, Mod+- and Mod+0 — so the buttons and the keys are one path.
67
+ * Over a trial with no camera the group stays, disabled, so the header does not
68
+ * reflow as the focus moves.
69
+ */
70
+ export function LabZoom() {
71
+ return (
72
+ <DepRegistryProvider>
73
+ <ActionsProvider>
74
+ <ActiveToolContextProvider>
75
+ <LabZoomControls />
76
+ </ActiveToolContextProvider>
77
+ </ActionsProvider>
78
+ </DepRegistryProvider>
79
+ );
80
+ }
81
+
82
+ function LabZoomControls() {
83
+ const registry = useActionsRegistry() as ActionsRegistry;
84
+ const { focusedTrialId } = useLabContext();
85
+ const cameras = useContext(CameraRegistryContext);
86
+ const storeCtx = useContext(LabStoreContext);
87
+ if (!storeCtx) throw new Error('[labkit] <LabZoom> requires <LabStoreProvider>');
88
+
89
+ const camera = useSyncExternalStore(cameras?.subscribe ?? noSubscribe, () =>
90
+ cameras && focusedTrialId ? cameras.get(focusedTrialId) : null,
91
+ );
92
+ const zoom = useStore(storeCtx.store, (s) => {
93
+ const record = s.trials.find((t) => t.id === focusedTrialId);
94
+ return as2DView(record?.view)?.zoom ?? null;
95
+ });
96
+
97
+ const live = zoom !== null && camera !== null;
98
+ const view = useMemo(() => (camera ? forZoom(camera) : null), [camera]);
99
+ const viewRef = useRef(view);
100
+ viewRef.current = live ? view : null;
101
+
102
+ useDepSource('view', () => viewRef.current as ViewApi);
103
+
104
+ useEffect(() => {
105
+ const action: Action = {
106
+ ...BASE,
107
+ defaultBinding: KEY_BINDINGS as Action['defaultBinding'],
108
+ enabled: () => (viewRef.current ? true : ActionDisabledReason.NotApplicable),
109
+ };
110
+ return registry.register(action);
111
+ }, [registry]);
112
+
113
+ // First, so a story's own zoom keys do not also fire over a trial with a
114
+ // camera; over one without, the action is disabled and the keys pass on.
115
+ useGestureDispatcher({
116
+ canvasRef: NO_ELEMENT,
117
+ actions: registry,
118
+ entriesById: NO_TOOLS,
119
+ channels: CHANNELS,
120
+ keyboard: 'first',
121
+ });
122
+
123
+ const range = camera?.zoomRange();
124
+ // A step that lands within rounding of a limit has reached it.
125
+ const canIn = zoom !== null && range !== undefined && zoom < range.max * (1 - 1e-9);
126
+ const canOut = zoom !== null && range !== undefined && zoom > range.min * (1 + 1e-9);
127
+ const run = (kind: ZoomKind) => () => {
128
+ registry.trigger(ZOOM_ID, { kind });
129
+ };
130
+ const hint = (label: string, kind: ZoomKind): string => {
131
+ const keys = shortcutFor(kind);
132
+ return keys ? `${label} (${keys})` : label;
133
+ };
134
+ const shown = live && zoom !== null ? percent.format(zoom) : null;
135
+
136
+ return (
137
+ <Toolbar aria-label="Zoom">
138
+ <Toolbar.Group aria-label="Zoom">
139
+ <Toolbar.Button
140
+ iconOnly
141
+ aria-label="Zoom out"
142
+ title={hint('Zoom out', 'out')}
143
+ disabled={!canOut}
144
+ onClick={run('out')}
145
+ >
146
+ <ZoomOutIcon size={16} />
147
+ </Toolbar.Button>
148
+ <Toolbar.Button
149
+ aria-label={shown ? `Zoom ${shown} — reset to actual size` : 'Zoom unavailable'}
150
+ title={live ? hint('Actual size', 'reset') : 'This trial has no camera'}
151
+ disabled={!live}
152
+ onClick={run('reset')}
153
+ >
154
+ <span className="lk-lab-zoom__readout">{shown ?? '–'}</span>
155
+ </Toolbar.Button>
156
+ <Toolbar.Button
157
+ iconOnly
158
+ aria-label="Zoom in"
159
+ title={hint('Zoom in', 'in')}
160
+ disabled={!canIn}
161
+ onClick={run('in')}
162
+ >
163
+ <ZoomInIcon size={16} />
164
+ </Toolbar.Button>
165
+ </Toolbar.Group>
166
+ </Toolbar>
167
+ );
168
+ }
169
+
170
+ const noSubscribe = (): (() => void) => () => {};
package/src/lab/index.ts CHANGED
@@ -11,5 +11,6 @@ export type { LabShellProps } from './LabShell';
11
11
  export { LabShell } from './LabShell';
12
12
  export type { LabPage, LabSwitcherProps } from './LabSwitcher';
13
13
  export { currentPage, LabSwitcher } from './LabSwitcher';
14
+ export { LabZoom } from './LabZoom';
14
15
  export type { PanelDescriptor, TrialLayout, WorkspaceProps } from './Workspace';
15
16
  export { Workspace } from './Workspace';
@@ -14,7 +14,7 @@ This directory binds that model to a labkit trial and draws it.
14
14
  | `types.ts` | `LoupeCapability`, and `resolveLoupe` filling in every default |
15
15
  | `useLoupe.ts` | The model over a host element, and pointer aiming |
16
16
  | `loupeActions.ts` | `loupe.peek` and `loupe.magnify`, as `Action` descriptors |
17
- | `LoupeGestures.tsx` | Registers those and mounts the dispatcher on the host |
17
+ | `LoupeGestures.tsx` | Registers those, and mounts a dispatcher on the host when no camera has one there |
18
18
  | `TrialLoupe.tsx` | Picks the painter and mounts the lens |
19
19
  | `LoupeBubble.tsx` | The circular clip, positioned on the aim |
20
20
  | `CanvasLoupe.tsx` | Painter for a `<CanvasStack>` |
@@ -46,11 +46,12 @@ StrictMode mounts / unmounts / mounts every effect — so disposing in the
46
46
  cleanup leaves a magnifier that draws but silently ignores every aim. It owns
47
47
  no resources; unmounting only reports the lens gone.
48
48
 
49
- **The wheel is taken from pan/zoom by declining it, not by capturing it.**
50
- `loupe.magnify`'s `enabled` returns a disabled reason while the lens is down, so
51
- the dispatcher leaves the event unhandled and `usePanZoom` — a React handler on
52
- the same element — sees it as usual. While the lens is up the action fires, the
53
- dispatcher stops propagation, and React's root listener never runs.
49
+ **The wheel is taken from the camera by rank, and handed back by declining.**
50
+ In a trial the loupe's actions join the camera's dispatcher (`CameraInput`).
51
+ `loupe.magnify` sits in the hotkey tier, so while the lens is up it outranks
52
+ the camera's wheel zoom; while it is down its `enabled` returns a disabled
53
+ reason and the dispatcher falls through to the zoom. Outside a camera — the DOM
54
+ `.lk-trial__loupe-host` — `<LoupeGestures>` mounts a dispatcher of its own.
54
55
 
55
56
  **Aiming is a plain listener because a hover is not a gesture.**
56
57
  `GESTURE_DESCRIPTORS` names no continuous-motion gesture, so `pointermove` /
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Mounts the gesture dispatcher on a loupe's host and registers the loupe's
3
- * actions on it, so the peek key and the wheel route the way every other
4
- * weasel binding does.
2
+ * Registers the loupe's actions, and mounts the gesture dispatcher on the
3
+ * loupe's host when no camera already has one there, so the peek key and the
4
+ * wheel route the way every other weasel binding does.
5
5
  *
6
6
  * Three of the dispatcher's four element channels are off. Only `wheel` is
7
7
  * wanted here; `contextMenu` suppresses the native menu unconditionally and
@@ -14,7 +14,8 @@ import {
14
14
  useActionsRegistry,
15
15
  useGestureDispatcher,
16
16
  } from '@weasel-js/core';
17
- import { type RefObject, useEffect, useMemo } from 'react';
17
+ import { type RefObject, useContext, useEffect, useMemo } from 'react';
18
+ import { CameraContext } from '../canvas/CameraInput';
18
19
  import { createLoupeActions, type LoupeInputApi } from './loupeActions';
19
20
 
20
21
  /** Props for `<LoupeGestures>`. */
@@ -35,7 +36,8 @@ function LoupeDispatch({
35
36
  input,
36
37
  peekKey,
37
38
  registry,
38
- }: LoupeGesturesProps & { registry: ActionsRegistry }) {
39
+ ownDispatcher,
40
+ }: LoupeGesturesProps & { registry: ActionsRegistry; ownDispatcher: boolean }) {
39
41
  const actions = useMemo(() => createLoupeActions(input, peekKey), [input, peekKey]);
40
42
 
41
43
  useEffect(() => {
@@ -48,20 +50,23 @@ function LoupeDispatch({
48
50
  useGestureDispatcher({
49
51
  canvasRef: hostRef,
50
52
  actions: registry,
51
- toolsById: NO_TOOLS,
53
+ entriesById: NO_TOOLS,
52
54
  channels: CHANNELS,
55
+ enabled: ownDispatcher,
53
56
  });
54
57
 
55
58
  return null;
56
59
  }
57
60
 
58
61
  /**
59
- * The loupe's input, routed. Render it inside a `<WeaselProvider isolate>` —
60
- * with no registry in scope it renders nothing rather than registering
61
- * actions that could never fire.
62
+ * The loupe's input, routed. Inside a camera (`<CanvasStack>`, `<Stage>`) its
63
+ * actions join the camera's dispatcher, which already listens on the host;
64
+ * elsewhere it mounts a dispatcher of its own. With no registry in scope it
65
+ * renders nothing rather than registering actions that could never fire.
62
66
  */
63
67
  export function LoupeGestures(props: LoupeGesturesProps) {
64
68
  const registry = useActionsRegistry();
69
+ const camera = useContext(CameraContext);
65
70
  if (!registry) return null;
66
- return <LoupeDispatch {...props} registry={registry} />;
71
+ return <LoupeDispatch {...props} registry={registry} ownDispatcher={camera === null} />;
67
72
  }
@@ -1,5 +1,5 @@
1
- import { WeaselProvider } from '@weasel-js/core';
2
1
  import { type RefObject, useContext, useMemo } from 'react';
2
+ import { CameraScope } from '../canvas/CameraInput';
3
3
  import { CanvasStackContext } from '../canvas/CanvasStackContext';
4
4
  import type { WorldSpec } from '../canvas/worldSpec';
5
5
  import type { ViewTransform } from '../instrument/types';
@@ -63,12 +63,12 @@ export function TrialLoupe({
63
63
 
64
64
  // The gestures mount outside the visibility gate: hold-to-peek is what
65
65
  // raises a lens that is down, so its binding has to be live while it is.
66
- // One isolated scope per loupe, for the reason `<AnnotationOverlay>` gives
67
- // — an actions registry holds one dispatcher.
66
+ // In a trial they join the trial's scope, and with it the camera's
67
+ // dispatcher; outside one, `<CameraScope>` gives them an isolated scope.
68
68
  const gestures = (
69
- <WeaselProvider isolate>
69
+ <CameraScope>
70
70
  <LoupeGestures hostRef={host} input={loupe.input} peekKey={capability.peekKey ?? null} />
71
- </WeaselProvider>
71
+ </CameraScope>
72
72
  );
73
73
 
74
74
  if (!loupe.visible) return gestures;
@@ -1,3 +1,4 @@
1
+ export type { LoupeMode, LoupePoint } from '@weasel-js/loupe';
1
2
  export type { CanvasLoupeProps } from './CanvasLoupe';
2
3
  export { CanvasLoupe } from './CanvasLoupe';
3
4
  export type { LensCamera, SourceRect } from './canvasLens';
@@ -21,8 +21,8 @@ export const WHEEL_RATE = 0.002;
21
21
  * registration. */
22
22
  export interface LoupeInputApi {
23
23
  /** Whether the lens is up. The wheel belongs to the loupe only while it is;
24
- * otherwise `loupe.magnify` declines and the dispatcher leaves the event
25
- * unhandled, which is what hands it back to the lab's own pan/zoom. */
24
+ * otherwise `loupe.magnify` declines and the dispatcher falls through to
25
+ * the trial camera's zoom. */
26
26
  shown: () => boolean;
27
27
  /** Raise or drop the hold-to-peek flag. */
28
28
  setPeeking: (on: boolean) => void;
@@ -43,6 +43,10 @@ export function createLoupeActions(
43
43
  id: LOUPE_MAGNIFY_ID,
44
44
  label: 'Magnify (loupe)',
45
45
  group: 'loupe',
46
+ // Hotkey scope, so while the lens is up it outranks the trial camera's
47
+ // wheel zoom on the same dispatcher; while it is down `enabled` declines
48
+ // and the zoom runs.
49
+ scope: 'hotkey',
46
50
  // Every modifier optional: the lens claims the wheel whenever it is up,
47
51
  // which is what the hand-rolled capture-phase listener did. A bare spec
48
52
  // would forbid modifiers and hand Cmd+wheel back to the lab mid-peek.
@@ -0,0 +1,38 @@
1
+ # Overview — Agent Guide
2
+
3
+ `src/overview/` is `@weasel-js/labkit/overview`: `<TrialOverview>`, a trial's
4
+ whole content in a floating panel. Its own bundle entry; nothing in the main
5
+ bundle imports it.
6
+
7
+ | File | Role |
8
+ |---|---|
9
+ | `TrialOverview.tsx` | The panel: content, chrome canvas, and its own dispatcher |
10
+ | `OverviewMarks.tsx` | Every annotation target's marks, read-only, as SVG |
11
+
12
+ ## How it reads the trial
13
+
14
+ Only through `CameraContext`, which `<CanvasStack>` and `<Stage>` set: the
15
+ camera as a weasel `ViewApi` over frame-local coordinates, the frame, the
16
+ camera's element, a stage's content rect. A canvas instrument's layers come
17
+ from `CanvasStackContext`, marks from `AnnotationsContext`. So it works
18
+ anywhere inside a camera — `stage.overlay`, or a canvas instrument's `render`.
19
+
20
+ Input is core's `minimap.center` / `minimap.pan` on a dispatcher of its own,
21
+ with the camera as `rootView`; the pointer store is the trial's, shared.
22
+
23
+ ## Traps
24
+
25
+ **It sits inside the stage's element.** Every event that bubbles out of the box
26
+ reaches the stage's dispatcher, the loupe's aim and the stage's pointer
27
+ publisher. The box stops them; drop that and a drag in the overview also pans
28
+ the stage, and the pointer flips between `'overview'` and `'stage'` each move.
29
+
30
+ **Marks are placed by measuring.** A target's rect on the overview is its
31
+ element's rect against the camera's element, through the camera — never the
32
+ target's content size, which is the mark's world and not where it is drawn.
33
+ jsdom measures zero; a test mocks `getBoundingClientRect`, and the picture is a
34
+ screenshot.
35
+
36
+ **Never render the instrument's own `render` in it.** Its effects — a pane
37
+ ref, an annotation target — would run twice. `render` is a separate, lighter
38
+ copy the instrument supplies.