@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,376 @@
1
+ /**
2
+ * `<TrialOverview>` — the whole of a trial's content at a glance, in a panel
3
+ * that floats over the stage: the visible rect, a crosshair where the pointer
4
+ * is on the stage, and a press or drag that moves the stage's camera there.
5
+ *
6
+ * It is the labkit form of core's minimap contribution: the same
7
+ * `minimap.center` / `minimap.pan` actions, run on a dispatcher of its own with
8
+ * the trial's camera as `rootView`, and the same crosshair geometry.
9
+ */
10
+ import {
11
+ ActionsProvider,
12
+ ActiveToolContextProvider,
13
+ crosshairRects,
14
+ DepRegistryProvider,
15
+ MINIMAP_CENTER,
16
+ MINIMAP_PAN,
17
+ minimapCenterAction,
18
+ minimapPanAction,
19
+ type Tool,
20
+ useActionsRegistry,
21
+ useDepSource,
22
+ useGestureDispatcher,
23
+ usePointerPosition,
24
+ type View,
25
+ type ViewApi,
26
+ } from '@weasel-js/core';
27
+ import {
28
+ type CSSProperties,
29
+ type ReactNode,
30
+ type RefObject,
31
+ useContext,
32
+ useEffect,
33
+ useLayoutEffect,
34
+ useMemo,
35
+ useRef,
36
+ } from 'react';
37
+ import {
38
+ CameraContext,
39
+ type CameraContextValue,
40
+ pointerElsewhere,
41
+ usePublishPointer,
42
+ } from '../canvas/CameraInput';
43
+ import { CanvasStackContext } from '../canvas/CanvasStackContext';
44
+ import { fromCameraView } from '../canvas/cameraView';
45
+ import type { ViewportSize, WorldFrame } from '../canvas/worldSpec';
46
+ import { FloatingPanel, type FloatingPanelProps } from '../primitives/FloatingPanel';
47
+ import { OverviewMarks } from './OverviewMarks';
48
+
49
+ /** The view id the overview publishes its pointer under. */
50
+ export const OVERVIEW_VIEW_ID = 'overview';
51
+
52
+ /** A rect in the instrument's world. */
53
+ export interface OverviewBounds {
54
+ x: number;
55
+ y: number;
56
+ width: number;
57
+ height: number;
58
+ }
59
+
60
+ /** What an overview's `render` is handed. */
61
+ export interface OverviewRenderArgs {
62
+ /** The content's own size in CSS pixels, at zoom 1 — lay out at this size
63
+ * and the overview scales it to fit. */
64
+ size: ViewportSize;
65
+ }
66
+
67
+ /** Props for `<TrialOverview>`. */
68
+ export interface TrialOverviewProps {
69
+ /** The overview box, in CSS pixels. */
70
+ width: number;
71
+ height: number;
72
+ /**
73
+ * DOM content for the overview, laid out at the content's own size — for a
74
+ * `stage` instrument, typically a lighter copy of what its `render` draws.
75
+ * Never the instrument's own `render`: its effects would run twice. A canvas
76
+ * instrument omits it, and the overview redraws the canvas's layers.
77
+ */
78
+ render?: (args: OverviewRenderArgs) => ReactNode;
79
+ /** What to frame, in the instrument's world. Defaults to a stage's content
80
+ * rect; a canvas instrument, whose world has no edge, names one. */
81
+ bounds?: OverviewBounds;
82
+ /** Panel title. */
83
+ title?: string;
84
+ anchor?: FloatingPanelProps['anchor'];
85
+ persist?: string;
86
+ className?: string;
87
+ }
88
+
89
+ const PADDING = 6;
90
+ const OVERVIEW_INPUT: Tool = {
91
+ id: 'overview',
92
+ eligibility: { always: true },
93
+ bindings: [
94
+ { spec: { kind: 'pointerDown' }, actionId: MINIMAP_CENTER },
95
+ { spec: { kind: 'drag' }, actionId: MINIMAP_PAN },
96
+ ],
97
+ };
98
+ const TOOLS_BY_ID: ReadonlyMap<string, Tool> = new Map([[OVERVIEW_INPUT.id, OVERVIEW_INPUT]]);
99
+ const CHANNELS = { wheel: false, pinch: false, contextMenu: false, ingest: false } as const;
100
+
101
+ /** A world rect as a frame-local one. */
102
+ function toLocalRect(b: OverviewBounds, frame: WorldFrame): OverviewBounds {
103
+ if (frame.yDir === 1) return b;
104
+ return { x: b.x, y: -(b.y + b.height), width: b.width, height: b.height };
105
+ }
106
+
107
+ /** The camera that fits `rect` into `size`, `PADDING` in from every edge. */
108
+ function fitRect(rect: OverviewBounds, size: ViewportSize): View {
109
+ const s = Math.min(
110
+ Math.max(1, size.width - 2 * PADDING) / Math.max(rect.width, 1e-9),
111
+ Math.max(1, size.height - 2 * PADDING) / Math.max(rect.height, 1e-9),
112
+ );
113
+ return {
114
+ x: rect.x + rect.width / 2 - size.width / (2 * s),
115
+ y: rect.y + rect.height / 2 - size.height / (2 * s),
116
+ scale: { x: s, y: s },
117
+ };
118
+ }
119
+
120
+ /** A canvas's 2D context sized to `size` at the device pixel ratio. */
121
+ function prepare(
122
+ canvas: HTMLCanvasElement | null,
123
+ size: ViewportSize,
124
+ ): CanvasRenderingContext2D | null {
125
+ const ctx = canvas?.getContext('2d') ?? null;
126
+ if (!canvas || !ctx) return null;
127
+ const dpr = window.devicePixelRatio || 1;
128
+ const w = Math.round(size.width * dpr);
129
+ const h = Math.round(size.height * dpr);
130
+ if (canvas.width !== w) canvas.width = w;
131
+ if (canvas.height !== h) canvas.height = h;
132
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
133
+ ctx.clearRect(0, 0, size.width, size.height);
134
+ return ctx;
135
+ }
136
+
137
+ function cssVar(el: Element | null, name: string, fallback: string): string {
138
+ if (!el) return fallback;
139
+ return getComputedStyle(el).getPropertyValue(name).trim() || fallback;
140
+ }
141
+
142
+ /**
143
+ * A trial's overview. Mount it from an instrument's `render` (a canvas
144
+ * instrument) or `stage.overlay` (a DOM stage) — anywhere inside the trial's
145
+ * camera.
146
+ */
147
+ export function TrialOverview(props: TrialOverviewProps) {
148
+ const camera = useContext(CameraContext);
149
+ if (!camera) return null;
150
+ return <Overview {...props} camera={camera} />;
151
+ }
152
+
153
+ function Overview({
154
+ width,
155
+ height,
156
+ render,
157
+ bounds,
158
+ title = 'Overview',
159
+ anchor = 'bottom-left',
160
+ persist,
161
+ className,
162
+ camera,
163
+ }: TrialOverviewProps & { camera: CameraContextValue }) {
164
+ const size = useMemo(() => ({ width, height }), [width, height]);
165
+ const content = bounds ? toLocalRect(bounds, camera.frame) : camera.content;
166
+ // `content` is rebuilt every render, so the fit keys on its numbers.
167
+ const framed = content !== null && content !== undefined;
168
+ const { x = 0, y = 0, width: w = 0, height: h = 0 } = content ?? {};
169
+ const fit = useMemo(
170
+ () =>
171
+ framed ? fitRect({ x, y, width: w, height: h }, size) : { x: 0, y: 0, scale: { x: 1, y: 1 } },
172
+ [framed, x, y, w, h, size],
173
+ );
174
+ const boxRef = useRef<HTMLDivElement | null>(null);
175
+
176
+ return (
177
+ <FloatingPanel
178
+ anchor={anchor}
179
+ className={className ? `lk-overview ${className}` : 'lk-overview'}
180
+ {...(persist ? { persist } : {})}
181
+ >
182
+ <div className="lk-overview__title">{title}</div>
183
+ <div ref={boxRef} className="lk-overview__box" data-no-drag style={boxVars(size)}>
184
+ {render && content ? (
185
+ <div className="lk-overview__content" style={contentVars(fit, content)}>
186
+ {render({ size: { width: content.width, height: content.height } })}
187
+ </div>
188
+ ) : null}
189
+ {render ? null : <LayersCanvas fit={fit} size={size} frame={camera.frame} />}
190
+ <OverviewMarks fit={fit} camera={camera} />
191
+ <ChromeCanvas fit={fit} size={size} camera={camera} />
192
+ <OverviewInput boxRef={boxRef} fit={fit} camera={camera} />
193
+ </div>
194
+ </FloatingPanel>
195
+ );
196
+ }
197
+
198
+ // The box's size and the content's camera change with props, and a transform
199
+ // has nowhere to live but the element's own style: set as custom properties
200
+ // the stylesheet reads, as `<Stage>` does.
201
+ function boxVars(size: ViewportSize): CSSProperties {
202
+ return {
203
+ ['--lk-overview-w' as string]: `${size.width}px`,
204
+ ['--lk-overview-h' as string]: `${size.height}px`,
205
+ } as CSSProperties;
206
+ }
207
+
208
+ function contentVars(fit: View, content: OverviewBounds): CSSProperties {
209
+ const s = fit.scale.x;
210
+ return {
211
+ ['--lk-overview-x' as string]: `${(content.x - fit.x) * s}px`,
212
+ ['--lk-overview-y' as string]: `${(content.y - fit.y) * s}px`,
213
+ ['--lk-overview-zoom' as string]: String(s),
214
+ ['--lk-overview-cw' as string]: `${content.width}px`,
215
+ ['--lk-overview-ch' as string]: `${content.height}px`,
216
+ } as CSSProperties;
217
+ }
218
+
219
+ /** A canvas instrument's layers, redrawn through the fit camera. */
220
+ function LayersCanvas({ fit, size, frame }: { fit: View; size: ViewportSize; frame: WorldFrame }) {
221
+ const stack = useContext(CanvasStackContext);
222
+ const ref = useRef<HTMLCanvasElement | null>(null);
223
+ const layers = stack?.surface?.layers;
224
+ useLayoutEffect(() => {
225
+ const ctx = prepare(ref.current, size);
226
+ if (!ctx || !layers) return;
227
+ const local: WorldFrame = { originPx: { x: 0, y: 0 }, yDir: frame.yDir };
228
+ const view = fromCameraView(fit, local);
229
+ for (const layer of layers) {
230
+ if (!layer.visible || layer.id.startsWith('__lk_')) continue;
231
+ ctx.save();
232
+ layer.render(ctx, view, local);
233
+ ctx.restore();
234
+ }
235
+ }, [layers, fit, size, frame.yDir]);
236
+ return <canvas ref={ref} className="lk-overview__canvas" />;
237
+ }
238
+
239
+ /** The visible-rect indicator and the linked crosshair. */
240
+ function ChromeCanvas({
241
+ fit,
242
+ size,
243
+ camera,
244
+ }: {
245
+ fit: View;
246
+ size: ViewportSize;
247
+ camera: CameraContextValue;
248
+ }) {
249
+ const ref = useRef<HTMLCanvasElement | null>(null);
250
+ const pointer = usePointerPosition();
251
+ const root = camera.view.get();
252
+ const host = camera.view.hostSize?.() ?? null;
253
+ // No dependency list: this renders only when the pointer or the trial's
254
+ // camera moved, and either one moves the chrome.
255
+ useLayoutEffect(() => {
256
+ const ctx = prepare(ref.current, size);
257
+ if (!ctx) return;
258
+ const s = fit.scale.x;
259
+ const px = (x: number, y: number) => ({ x: (x - fit.x) * s, y: (y - fit.y) * s });
260
+ if (host) {
261
+ const a = px(root.x, root.y);
262
+ ctx.strokeStyle = cssVar(ref.current, '--wzl-fg', '#ffffff');
263
+ ctx.lineWidth = 1;
264
+ ctx.setLineDash([2, 3]);
265
+ ctx.strokeRect(
266
+ a.x + 0.5,
267
+ a.y + 0.5,
268
+ (host.width / root.scale.x) * s,
269
+ (host.height / root.scale.y) * s,
270
+ );
271
+ ctx.setLineDash([]);
272
+ }
273
+ const world = pointerElsewhere(pointer, OVERVIEW_VIEW_ID, camera.frame);
274
+ if (world) {
275
+ // Back to frame-local: the map is its own inverse.
276
+ const at = px(world.x, world.y * camera.frame.yDir);
277
+ const { halo, bars } = crosshairRects(at.x, at.y, 1);
278
+ ctx.fillStyle = 'rgb(0 0 0 / 60%)';
279
+ for (const r of halo) ctx.fillRect(r.x, r.y, r.w, r.h);
280
+ ctx.fillStyle = cssVar(ref.current, '--wzl-accent', '#4c8dff');
281
+ for (const r of bars) ctx.fillRect(r.x, r.y, r.w, r.h);
282
+ }
283
+ });
284
+ return <canvas ref={ref} className="lk-overview__canvas lk-overview__chrome" />;
285
+ }
286
+
287
+ /**
288
+ * The overview's input, on a dispatcher of its own: its actions and deps are
289
+ * isolated from the trial's, its pointer store is shared with it.
290
+ */
291
+ function OverviewInput(props: {
292
+ boxRef: RefObject<HTMLDivElement | null>;
293
+ fit: View;
294
+ camera: CameraContextValue;
295
+ }) {
296
+ return (
297
+ <DepRegistryProvider>
298
+ <ActionsProvider>
299
+ <ActiveToolContextProvider>
300
+ <OverviewDispatch {...props} />
301
+ </ActiveToolContextProvider>
302
+ </ActionsProvider>
303
+ </DepRegistryProvider>
304
+ );
305
+ }
306
+
307
+ function OverviewDispatch({
308
+ boxRef,
309
+ fit,
310
+ camera,
311
+ }: {
312
+ boxRef: RefObject<HTMLDivElement | null>;
313
+ fit: View;
314
+ camera: CameraContextValue;
315
+ }) {
316
+ const registry = useActionsRegistry();
317
+ const fitRef = useRef(fit);
318
+ fitRef.current = fit;
319
+ const root = camera.view;
320
+ useDepSource('rootView', () => root as ViewApi);
321
+
322
+ useEffect(() => {
323
+ if (!registry) return;
324
+ const offs = [registry.register(minimapCenterAction()), registry.register(minimapPanAction())];
325
+ return () => {
326
+ for (const off of offs) off();
327
+ };
328
+ }, [registry]);
329
+
330
+ const clientToLocal = useMemo(
331
+ () => (cx: number, cy: number) => {
332
+ const r = boxRef.current?.getBoundingClientRect();
333
+ const f = fitRef.current;
334
+ return {
335
+ x: (cx - (r?.left ?? 0)) / f.scale.x + f.x,
336
+ y: (cy - (r?.top ?? 0)) / f.scale.y + f.y,
337
+ };
338
+ },
339
+ [boxRef],
340
+ );
341
+
342
+ useGestureDispatcher({
343
+ canvasRef: boxRef,
344
+ // Always present: `OverviewInput` mounts the provider directly above.
345
+ actions: registry as NonNullable<typeof registry>,
346
+ entriesById: TOOLS_BY_ID,
347
+ clientToWorld: clientToLocal,
348
+ keyboard: false,
349
+ channels: CHANNELS,
350
+ });
351
+
352
+ usePublishPointer(boxRef, clientToLocal, camera.frame, OVERVIEW_VIEW_ID);
353
+
354
+ // The box sits inside the stage's element, whose own dispatcher, loupe and
355
+ // pointer publisher would otherwise take every event that bubbles out of it.
356
+ useEffect(() => {
357
+ const el = boxRef.current;
358
+ if (!el) return;
359
+ const stop = (e: Event): void => e.stopPropagation();
360
+ const kinds = [
361
+ 'pointerdown',
362
+ 'pointermove',
363
+ 'pointerup',
364
+ 'click',
365
+ 'wheel',
366
+ 'dblclick',
367
+ 'contextmenu',
368
+ ];
369
+ for (const k of kinds) el.addEventListener(k, stop);
370
+ return () => {
371
+ for (const k of kinds) el.removeEventListener(k, stop);
372
+ };
373
+ }, [boxRef]);
374
+
375
+ return null;
376
+ }
@@ -0,0 +1,2 @@
1
+ export type { OverviewBounds, OverviewRenderArgs, TrialOverviewProps } from './TrialOverview';
2
+ export { OVERVIEW_VIEW_ID, TrialOverview } from './TrialOverview';
@@ -20,6 +20,7 @@ import {
20
20
  paintGradientTrack,
21
21
  RadioGroup,
22
22
  RangeSlider,
23
+ sampleByInterpolation,
23
24
  Select,
24
25
  Sidebar,
25
26
  Slider,
@@ -38,6 +39,7 @@ describe('weasel-ui passthrough', () => {
38
39
  expect(typeof useReorderDragList).toBe('function');
39
40
  expect(typeof paintGradientTrack).toBe('function');
40
41
  expect(typeof oklchToHex).toBe('function');
42
+ expect(typeof sampleByInterpolation).toBe('function');
41
43
  expect(typeof chromaAt).toBe('function');
42
44
  });
43
45
 
@@ -5,6 +5,7 @@
5
5
  * future without consumer churn.
6
6
  */
7
7
 
8
+ export { formatNumber, MINUS_SIGN } from '@weasel-js/quantity';
8
9
  export {
9
10
  ActionBar,
10
11
  type ActionBarProps,
@@ -21,6 +22,7 @@ export {
21
22
  type BadgeVariant,
22
23
  type BoundsCtx,
23
24
  type BuiltInEdgeName,
25
+ type BuiltInProfileName,
24
26
  type BuiltinPref,
25
27
  Button,
26
28
  ButtonBar,
@@ -38,6 +40,9 @@ export {
38
40
  type CheckboxProps,
39
41
  type ChromaCurve,
40
42
  type ChromaCurvePoint,
43
+ CloseButton,
44
+ type CloseButtonProps,
45
+ COLOR_MODE_OPTIONS,
41
46
  ColorModeControl,
42
47
  type ColorModeControlProps,
43
48
  ComboBox,
@@ -65,8 +70,8 @@ export {
65
70
  type DisclosureRowProps,
66
71
  DragGhost,
67
72
  type DragGhostProps,
68
- DragHandleGlyph,
69
- type DragHandleGlyphProps,
73
+ DragGrip,
74
+ type DragGripProps,
70
75
  detectPlatform,
71
76
  dlog,
72
77
  EDGE_PROFILES,
@@ -78,11 +83,13 @@ export {
78
83
  type FieldProps,
79
84
  type FillSettings,
80
85
  fieldClasses,
81
- formatNumber,
82
86
  formatShortcut,
83
87
  formatShortcutParts,
88
+ GestureRoute,
89
+ type GestureRouteProps,
84
90
  type GradientTrackOpts,
85
91
  type GridSettings,
92
+ gestureRouteSegments,
86
93
  ICON_PATHS,
87
94
  Icon,
88
95
  type IconName,
@@ -98,6 +105,7 @@ export {
98
105
  type KeyCapVariant,
99
106
  type KeycapKind,
100
107
  KeySequence,
108
+ type KeySequenceJoins,
101
109
  type KeySequenceProps,
102
110
  type KeySpec,
103
111
  keyGlyph,
@@ -115,7 +123,6 @@ export {
115
123
  MenuButton,
116
124
  type MenuButtonItem,
117
125
  type MenuButtonProps,
118
- MINUS_SIGN,
119
126
  moveLayers,
120
127
  NumberField,
121
128
  type NumberFieldProps,
@@ -124,6 +131,7 @@ export {
124
131
  type OptionsBarProps,
125
132
  type OptionsBarSize,
126
133
  type OptionsBarVariant,
134
+ type OverlayPortalProps,
127
135
  oklchToHex,
128
136
  type Platform,
129
137
  Plot2D,
@@ -137,6 +145,7 @@ export {
137
145
  type PowerlineSegment,
138
146
  type PrefBoolean,
139
147
  type PrefBooleanControl,
148
+ type PrefBooleanEncoding,
140
149
  type PrefColor,
141
150
  type PrefCustom,
142
151
  type PrefEnum,
@@ -159,6 +168,9 @@ export {
159
168
  PrefsForm,
160
169
  type PrefsFormProps,
161
170
  type PrefsLayout,
171
+ type PropertyAlign,
172
+ type PropertyDensity,
173
+ type PropertyMetricProps,
162
174
  paintGradientTrack,
163
175
  prefValueAtPath,
164
176
  Radio,
@@ -172,6 +184,7 @@ export {
172
184
  type ReorderGhost,
173
185
  type RovingItem,
174
186
  type RovingTabIndex,
187
+ type SegmentTooltipFields,
175
188
  Select,
176
189
  SelectItem,
177
190
  type SelectItemProps,
@@ -183,8 +196,10 @@ export {
183
196
  type SidebarProps,
184
197
  Slider,
185
198
  type SliderProps,
199
+ type StanceProps,
186
200
  Switch,
187
201
  type SwitchProps,
202
+ sampleByInterpolation,
188
203
  Tab,
189
204
  TabList,
190
205
  type TabListProps,
@@ -193,6 +208,9 @@ export {
193
208
  type TabProps,
194
209
  Tabs,
195
210
  type TabsProps,
211
+ ThemeSwitcher,
212
+ type ThemeSwitcherOption,
213
+ type ThemeSwitcherProps,
196
214
  type Thumb,
197
215
  type ThumbRenderCtx,
198
216
  type ThumbShape,
@@ -215,4 +233,5 @@ export {
215
233
  useReorderDragList,
216
234
  useRovingTabIndex,
217
235
  visiblePrefSubtree,
236
+ type WithoutPortalTarget,
218
237
  } from '@weasel-js/ui';
@@ -1,4 +1,5 @@
1
1
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { DRAG_THRESHOLD_PX } from '@weasel-js/core';
2
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
4
  import { createMemoryAdapter } from '../state/adapters';
4
5
  import { labPrefix } from '../state/labRecords';
@@ -130,6 +131,16 @@ describe('FloatingPanel dragging', () => {
130
131
  expect(panel.dataset.dragging).toBeUndefined();
131
132
  });
132
133
 
134
+ it('becomes a drag at the kit drag threshold, not before', () => {
135
+ const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
136
+ fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
137
+ fireEvent.pointerMove(panel, { clientX: 100 + DRAG_THRESHOLD_PX - 0.5, clientY: 100 });
138
+ expect(panel.dataset.dragging).toBeUndefined();
139
+ fireEvent.pointerMove(panel, { clientX: 100 + DRAG_THRESHOLD_PX, clientY: 100 });
140
+ expect(panel.dataset.dragging).toBe('true');
141
+ fireEvent.pointerUp(panel);
142
+ });
143
+
133
144
  it('a press that never moves is not a drag', () => {
134
145
  const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
135
146
  const before = panel.style.left;
@@ -1,4 +1,4 @@
1
- import { openPointerSession, type PointerSession } from '@weasel-js/core';
1
+ import { openPointerSession, type PointerSession, pastDragThreshold } from '@weasel-js/core';
2
2
  import type React from 'react';
3
3
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import {
@@ -41,9 +41,6 @@ function borderBoxOf(entry: ResizeObserverEntry): { w: number; h: number } {
41
41
  return { w: entry.contentRect.width, h: entry.contentRect.height };
42
42
  }
43
43
 
44
- /** How far the pointer must travel before a press becomes a drag. */
45
- const DRAG_THRESHOLD = 3;
46
-
47
44
  /** A pointerdown on one of these is the child's, not a drag. */
48
45
  function isInteractive(target: EventTarget | null): boolean {
49
46
  if (!(target instanceof Element)) return false;
@@ -158,7 +155,7 @@ export function FloatingPanel({
158
155
  {
159
156
  onMove: (ev) => {
160
157
  if (!dragging.current) {
161
- if (Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < DRAG_THRESHOLD) return;
158
+ if (!pastDragThreshold({ clientX: start.x, clientY: start.y }, ev)) return;
162
159
  dragging.current = { ...start };
163
160
  setDragging(true);
164
161
  origin.setPointerCapture?.(pointerId);
@@ -18,7 +18,7 @@
18
18
  height: 100%;
19
19
  background: var(--wzl-accent);
20
20
  transform-origin: left center;
21
- transition: transform 120ms linear;
21
+ transition: transform var(--wzl-motion-fast) linear;
22
22
  }
23
23
 
24
24
  // A job that never reports a total sweeps instead of filling, so the bar
@@ -10,6 +10,7 @@ export default meta;
10
10
  type Story = StoryObj<typeof Legend>;
11
11
 
12
12
  export const AllMarks: Story = {
13
+ tags: ['gallery'],
13
14
  args: {
14
15
  entries: [
15
16
  { key: 'contour', label: 'contour', color: '#7d7f86' },