@weasel-js/labkit 1.4.4 → 1.5.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 (219) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-8R6UmlvN.d.ts} +253 -31
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-retnAut7.d.ts} +18 -10
  7. package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
  8. package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
  9. package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
  10. package/dist/canvas/index.d.ts +17 -128
  11. package/dist/canvas/index.js +4 -2
  12. package/dist/chrome/index.d.ts +4 -92
  13. package/dist/chrome/index.js +9 -5
  14. package/dist/chunk-67SJLMC7.js +704 -0
  15. package/dist/chunk-67SJLMC7.js.map +1 -0
  16. package/dist/chunk-6ZDGOZQV.js +310 -0
  17. package/dist/chunk-6ZDGOZQV.js.map +1 -0
  18. package/dist/{chunk-MOM3GOVY.js → chunk-EKIICY6X.js} +111 -25
  19. package/dist/chunk-EKIICY6X.js.map +1 -0
  20. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  21. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  22. package/dist/{chunk-SMHP6XZ4.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-BKFVHKJH.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-BKFVHKJH.js.map → chunk-MUKOW3TC.js.map} +1 -1
  26. package/dist/chunk-NDRYLVOW.js +15 -0
  27. package/dist/chunk-NDRYLVOW.js.map +1 -0
  28. package/dist/{chunk-64ZCN3DA.js → chunk-NS64DXMN.js} +226 -159
  29. package/dist/chunk-NS64DXMN.js.map +1 -0
  30. package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
  31. package/dist/chunk-PGETSEDK.js.map +1 -0
  32. package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
  33. package/dist/chunk-QLSV2N3G.js.map +1 -0
  34. package/dist/chunk-RWNDDKOH.js +682 -0
  35. package/dist/chunk-RWNDDKOH.js.map +1 -0
  36. package/dist/chunk-S2SHZRD7.js +7681 -0
  37. package/dist/chunk-S2SHZRD7.js.map +1 -0
  38. package/dist/{chunk-U3IYHIAE.js → chunk-TQBBYTAF.js} +19 -82
  39. package/dist/chunk-TQBBYTAF.js.map +1 -0
  40. package/dist/chunk-UU3NJO6N.js +72 -0
  41. package/dist/chunk-UU3NJO6N.js.map +1 -0
  42. package/dist/chunk-WIG6XHJ7.js +35 -0
  43. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  44. package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
  45. package/dist/chunk-ZPH5WTWF.js.map +1 -0
  46. package/dist/config/index.d.ts +232 -0
  47. package/dist/config/index.js +6 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/controls/index.d.ts +2 -3
  50. package/dist/controls/index.js +3 -2
  51. package/dist/dragdrop/index.d.ts +2 -4
  52. package/dist/index.d.ts +110 -381
  53. package/dist/index.js +691 -817
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +3 -5
  56. package/dist/layers/index.js +2 -2
  57. package/dist/loupe/index.d.ts +6 -8
  58. package/dist/loupe/index.js +2 -2
  59. package/dist/passthrough/weasel-ui.d.ts +164 -122
  60. package/dist/passthrough/weasel-ui.js +1 -1
  61. package/dist/primitives/index.d.ts +53 -6
  62. package/dist/primitives/index.js +6 -4
  63. package/dist/state/index.d.ts +12 -7
  64. package/dist/state/index.js +16 -13
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/styles.css +84 -50
  67. package/dist/surface/index.d.ts +44 -7
  68. package/dist/surface/index.js +2 -1
  69. package/dist/ui/layers/index.js +1 -1
  70. package/dist/undo/index.d.ts +2 -4
  71. package/package.json +15 -8
  72. package/src/annotations/AnnotationOverlay.tsx +53 -5
  73. package/src/annotations/Annotations.overlay.test.tsx +85 -6
  74. package/src/annotations/drawOne.test.ts +1 -1
  75. package/src/annotations/drawOne.ts +2 -2
  76. package/src/annotations/paint.test.ts +2 -2
  77. package/src/annotations/paint.ts +1 -1
  78. package/src/annotations/preload.ts +6 -0
  79. package/src/annotations/store.test.ts +18 -0
  80. package/src/annotations/store.ts +5 -4
  81. package/src/annotations/svgNodes.test.ts +2 -2
  82. package/src/annotations/toolMap.ts +3 -3
  83. package/src/annotations/types.ts +6 -3
  84. package/src/canvas/CameraWheelContext.ts +12 -0
  85. package/src/canvas/CanvasStack.test.tsx +25 -0
  86. package/src/canvas/CanvasStack.tsx +31 -4
  87. package/src/canvas/Stage.less +18 -0
  88. package/src/canvas/Stage.test.tsx +186 -0
  89. package/src/canvas/Stage.tsx +134 -0
  90. package/src/canvas/camera.test.ts +17 -2
  91. package/src/canvas/camera.ts +9 -9
  92. package/src/canvas/index.ts +4 -0
  93. package/src/canvas/usePanZoom.ts +10 -5
  94. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  95. package/src/chrome/LabChrome.tsx +114 -0
  96. package/src/chrome/builtins.tsx +20 -31
  97. package/src/chrome/index.ts +15 -0
  98. package/src/chrome/labRegions.test.tsx +208 -0
  99. package/src/chrome/labTypes.ts +50 -0
  100. package/src/chrome/merge.ts +7 -9
  101. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  102. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  103. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  104. package/src/chrome/regions/StatusRegion.tsx +15 -8
  105. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  106. package/src/chrome/types.ts +47 -20
  107. package/src/config/builder.test.ts +4 -0
  108. package/src/config/builder.ts +16 -10
  109. package/src/config/declarationEmit.fixture.ts +11 -0
  110. package/src/config/declarationEmit.test.ts +51 -0
  111. package/src/config/entry.test.ts +46 -0
  112. package/src/config/index.ts +13 -1
  113. package/src/config/nodeClasses.test.ts +18 -0
  114. package/src/config/types.ts +3 -1
  115. package/src/controls/ControlPanel.test.tsx +16 -0
  116. package/src/controls/ControlPanel.tsx +5 -2
  117. package/src/fake-indexeddb-auto.d.ts +5 -0
  118. package/src/index.test.ts +7 -0
  119. package/src/index.ts +38 -5
  120. package/src/instrument/serializers.test.ts +22 -0
  121. package/src/instrument/serializers.ts +11 -0
  122. package/src/instrument/types.ts +25 -1
  123. package/src/lab/Lab.less +4 -13
  124. package/src/lab/Lab.persist.test.tsx +147 -0
  125. package/src/lab/Lab.stories.tsx +0 -2
  126. package/src/lab/Lab.surface.test.tsx +66 -3
  127. package/src/lab/Lab.test.tsx +91 -17
  128. package/src/lab/Lab.tsx +337 -124
  129. package/src/lab/LabFit.stories.less +18 -0
  130. package/src/lab/LabFit.stories.tsx +219 -0
  131. package/src/lab/LabFullChrome.stories.tsx +0 -3
  132. package/src/lab/LabHeader.test.tsx +40 -9
  133. package/src/lab/LabHeader.tsx +7 -13
  134. package/src/lab/LabPalette.tsx +13 -26
  135. package/src/lab/LabShell.less +49 -12
  136. package/src/lab/LabSwitcher.less +8 -1
  137. package/src/lab/Workspace.surface.test.tsx +2 -1
  138. package/src/lab/fitCheck.test.ts +57 -0
  139. package/src/lab/fitCheck.ts +125 -0
  140. package/src/loupe/AGENTS.md +1 -1
  141. package/src/loupe/Loupe.less +1 -1
  142. package/src/loupe/canvasLens.ts +1 -1
  143. package/src/loupe/types.ts +1 -1
  144. package/src/loupe/useLoupe.test.tsx +1 -1
  145. package/src/loupe/useLoupe.ts +1 -1
  146. package/src/passthrough/weasel-ui.ts +9 -0
  147. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  148. package/src/primitives/FloatingPanel.test.tsx +47 -16
  149. package/src/primitives/FloatingPanel.tsx +11 -25
  150. package/src/primitives/Split.less +14 -0
  151. package/src/primitives/Split.test.tsx +108 -0
  152. package/src/primitives/Split.tsx +159 -0
  153. package/src/primitives/ZoomControl.tsx +5 -2
  154. package/src/primitives/index.ts +2 -0
  155. package/src/state/Persistence.stories.tsx +75 -0
  156. package/src/state/Persistence.tsx +33 -0
  157. package/src/state/SingletonExperiment.test.tsx +47 -53
  158. package/src/state/SingletonExperiment.tsx +24 -13
  159. package/src/state/adapterContract.ts +116 -0
  160. package/src/state/adapters.test.ts +113 -61
  161. package/src/state/adapters.ts +328 -69
  162. package/src/state/document.test.ts +71 -96
  163. package/src/state/document.ts +66 -52
  164. package/src/state/helpers.test.ts +1 -1
  165. package/src/state/index.ts +8 -0
  166. package/src/state/labRecords.test.ts +105 -0
  167. package/src/state/labRecords.ts +160 -0
  168. package/src/state/openLabStore.test.ts +658 -0
  169. package/src/state/openLabStore.ts +403 -0
  170. package/src/state/records.test.ts +234 -0
  171. package/src/state/records.ts +219 -0
  172. package/src/state/store.test.ts +104 -635
  173. package/src/state/store.ts +116 -157
  174. package/src/state/toolSlot.test.ts +1 -1
  175. package/src/state/types.ts +39 -15
  176. package/src/state/useOpenOnce.ts +68 -0
  177. package/src/state/usePersistedState.test.tsx +125 -0
  178. package/src/state/usePersistedState.ts +66 -0
  179. package/src/state/useTrialState.test.tsx +2 -3
  180. package/src/state/view.test.ts +49 -15
  181. package/src/state/view.ts +18 -1
  182. package/src/styles.less +2 -0
  183. package/src/surface/AGENTS.md +39 -7
  184. package/src/surface/SurfaceContext.ts +26 -3
  185. package/src/surface/index.ts +2 -0
  186. package/src/surface/useSurfaceTile.test.tsx +2 -1
  187. package/src/surface/useSurfaceTile.ts +6 -5
  188. package/src/surface/useTiledSurface.test.tsx +175 -0
  189. package/src/surface/useTiledSurface.ts +54 -5
  190. package/src/theme/Interstellar.stories.tsx +7 -7
  191. package/src/theme/interstellar.test.ts +1 -1
  192. package/src/theme/interstellar.tokens.json +2 -2
  193. package/src/tools/labTool.ts +11 -0
  194. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  195. package/src/trial/Trial.annotations.test.tsx +47 -4
  196. package/src/trial/Trial.job.test.tsx +3 -3
  197. package/src/trial/Trial.less +1 -26
  198. package/src/trial/Trial.stories.tsx +1 -2
  199. package/src/trial/Trial.test.tsx +28 -4
  200. package/src/trial/Trial.trialId.test.tsx +23 -1
  201. package/src/trial/Trial.tsx +118 -38
  202. package/src/trial/TrialBody.tsx +17 -141
  203. package/src/trial/TrialChrome.tsx +7 -4
  204. package/src/trial/trialOps.ts +5 -3
  205. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  206. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  207. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  208. package/dist/chunk-2P6PP5N4.js +0 -553
  209. package/dist/chunk-2P6PP5N4.js.map +0 -1
  210. package/dist/chunk-64ZCN3DA.js.map +0 -1
  211. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  212. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  213. package/dist/chunk-I6JCVE24.js.map +0 -1
  214. package/dist/chunk-MOM3GOVY.js.map +0 -1
  215. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  216. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  217. package/dist/chunk-U3IYHIAE.js.map +0 -1
  218. package/dist/chunk-W3ECWC2K.js +0 -7335
  219. package/dist/chunk-W3ECWC2K.js.map +0 -1
@@ -1,5 +1,15 @@
1
- import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
1
+ import {
2
+ type CSSProperties,
3
+ type ReactNode,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
2
10
  import type { Point, ViewTransform } from '../instrument/types';
11
+ import { normalize2DView } from '../state/view';
12
+ import { CameraWheelContext } from './CameraWheelContext';
3
13
  import { CanvasStackContext } from './CanvasStackContext';
4
14
  import { screenToWorld } from './canvasCoords';
5
15
  import { type CanvasLayerDescriptor, useLayerScheduler } from './useLayerScheduler';
@@ -31,7 +41,7 @@ export interface CanvasStackProps {
31
41
  * Handles sizing, device pixel ratio, and pan/zoom. */
32
42
  export function CanvasStack({
33
43
  layers,
34
- view,
44
+ view: viewProp,
35
45
  onViewChange,
36
46
  worldSpec,
37
47
  minZoom,
@@ -43,6 +53,7 @@ export function CanvasStack({
43
53
  onHitTest,
44
54
  children,
45
55
  }: CanvasStackProps) {
56
+ const view = normalize2DView(viewProp);
46
57
  const containerRef = useRef<HTMLDivElement | null>(null);
47
58
  const canvasMap = useRef<Map<string, HTMLCanvasElement>>(new Map());
48
59
  const [size, setSize] = useState({ width: 0, height: 0, dpr: 1 });
@@ -88,7 +99,24 @@ export function CanvasStack({
88
99
  onHitTest(screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, view, frame));
89
100
  };
90
101
 
91
- const handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom, frame, onTap });
102
+ const handlers = usePanZoom({ view: viewProp, onViewChange, minZoom, maxZoom, frame, onTap });
103
+ const onWheelRef = useRef(handlers.onWheel);
104
+ onWheelRef.current = handlers.onWheel;
105
+ const wheelSlot = useContext(CameraWheelContext);
106
+
107
+ // Bound by hand because React registers wheel listeners passive, where
108
+ // `preventDefault` is refused and the page scrolls under the zoom.
109
+ useEffect(() => {
110
+ const el = containerRef.current;
111
+ if (!el) return;
112
+ const wheel = (e: WheelEvent): void => onWheelRef.current(e, el);
113
+ el.addEventListener('wheel', wheel, { passive: false });
114
+ if (wheelSlot) wheelSlot.current = wheel;
115
+ return () => {
116
+ el.removeEventListener('wheel', wheel);
117
+ if (wheelSlot?.current === wheel) wheelSlot.current = null;
118
+ };
119
+ }, [wheelSlot]);
92
120
  useLayerScheduler({ layers, view, frame, canvasRefs: canvasMap, size, host: containerRef });
93
121
 
94
122
  const ctxValue = useMemo(
@@ -116,7 +144,6 @@ export function CanvasStack({
116
144
  ref={containerRef}
117
145
  className={className ? `lk-canvas-stack ${className}` : 'lk-canvas-stack'}
118
146
  style={containerStyle}
119
- onWheel={handlers.onWheel}
120
147
  onPointerDown={handlers.onPointerDown}
121
148
  >
122
149
  {layers.map((layer) => (
@@ -0,0 +1,18 @@
1
+ .lk-stage {
2
+ position: relative;
3
+ width: 100%;
4
+ height: 100%;
5
+ overflow: hidden;
6
+ background: var(--wzl-surface-sunken);
7
+ touch-action: none;
8
+ }
9
+
10
+ .lk-stage__content {
11
+ position: absolute;
12
+ top: 0;
13
+ left: 0;
14
+ width: var(--lk-stage-w);
15
+ height: var(--lk-stage-h);
16
+ transform-origin: 0 0;
17
+ transform: translate(var(--lk-stage-x), var(--lk-stage-y)) scale(var(--lk-stage-zoom));
18
+ }
@@ -0,0 +1,186 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import { useState } from 'react';
3
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
4
+ import { defineInstrument } from '../instrument/defineInstrument';
5
+ import type { ViewTransform } from '../instrument/types';
6
+ import { Lab } from '../lab/Lab';
7
+ import { SurfaceContext } from '../surface/SurfaceContext';
8
+ import type { SurfaceHandle } from '../surface/useTiledSurface';
9
+ import { CameraWheelContext, type CameraWheelSlot } from './CameraWheelContext';
10
+ import { fitStage, Stage } from './Stage';
11
+
12
+ beforeAll(() => {
13
+ HTMLCanvasElement.prototype.getContext = vi.fn(
14
+ () => null,
15
+ ) as unknown as HTMLCanvasElement['getContext'];
16
+ });
17
+
18
+ function content(container: HTMLElement): HTMLElement {
19
+ const el = container.querySelector<HTMLElement>('.lk-stage__content');
20
+ if (!el) throw new Error('no stage content');
21
+ return el;
22
+ }
23
+
24
+ const zoomOf = (el: HTMLElement): number => Number(el.style.getPropertyValue('--lk-stage-zoom'));
25
+
26
+ describe('fitStage', () => {
27
+ it('shrinks content that does not fit, and centers it on the short axis', () => {
28
+ expect(fitStage({ width: 400, height: 200 }, { width: 200, height: 200 })).toEqual({
29
+ zoom: 0.5,
30
+ pan: { x: 0, y: 50 },
31
+ });
32
+ });
33
+
34
+ it('never enlarges content past its own size', () => {
35
+ expect(fitStage({ width: 100, height: 50 }, { width: 300, height: 150 })).toEqual({
36
+ zoom: 1,
37
+ pan: { x: 100, y: 50 },
38
+ });
39
+ });
40
+
41
+ it('answers the identity for an unmeasured viewport', () => {
42
+ expect(fitStage({ width: 100, height: 50 }, { width: 0, height: 0 })).toEqual({
43
+ zoom: 1,
44
+ pan: { x: 0, y: 0 },
45
+ });
46
+ });
47
+ });
48
+
49
+ describe('<Stage>', () => {
50
+ it('draws its content through the camera', () => {
51
+ const { container } = render(
52
+ <Stage
53
+ size={{ width: 300, height: 100 }}
54
+ view={{ zoom: 2, pan: { x: 10, y: 20 } }}
55
+ onViewChange={() => {}}
56
+ >
57
+ <div data-testid="art" />
58
+ </Stage>,
59
+ );
60
+ const el = content(container);
61
+ expect(el).toContainElement(screen.getByTestId('art'));
62
+ expect(zoomOf(el)).toBe(2);
63
+ expect(el.style.getPropertyValue('--lk-stage-x')).toBe('10px');
64
+ expect(el.style.getPropertyValue('--lk-stage-y')).toBe('20px');
65
+ expect(el.style.getPropertyValue('--lk-stage-w')).toBe('300px');
66
+ expect(el.style.getPropertyValue('--lk-stage-h')).toBe('100px');
67
+ });
68
+
69
+ it('zooms on the wheel', () => {
70
+ function Host() {
71
+ const [view, setView] = useState<ViewTransform>({ zoom: 1, pan: { x: 0, y: 0 } });
72
+ return (
73
+ <Stage size={{ width: 100, height: 100 }} view={view} onViewChange={setView}>
74
+ <div />
75
+ </Stage>
76
+ );
77
+ }
78
+ const { container } = render(<Host />);
79
+ const host = container.querySelector('.lk-stage');
80
+ if (!host) throw new Error('no stage');
81
+ fireEvent.wheel(host, { deltaY: -200, clientX: 0, clientY: 0 });
82
+ expect(zoomOf(content(container))).toBeGreaterThan(1);
83
+ });
84
+
85
+ it('zooms on a wheel handed to its camera from outside the stage', () => {
86
+ // What an annotation target's input box does: it is portalled out of the
87
+ // stage, so its wheel never bubbles here.
88
+ const slot: CameraWheelSlot = { current: null };
89
+ function Host() {
90
+ const [view, setView] = useState<ViewTransform>({ zoom: 1, pan: { x: 0, y: 0 } });
91
+ return (
92
+ <CameraWheelContext.Provider value={slot}>
93
+ <Stage size={{ width: 100, height: 100 }} view={view} onViewChange={setView}>
94
+ <div />
95
+ </Stage>
96
+ </CameraWheelContext.Provider>
97
+ );
98
+ }
99
+ const { container } = render(<Host />);
100
+ act(() => {
101
+ slot.current?.(new WheelEvent('wheel', { deltaY: -200, cancelable: true }));
102
+ });
103
+ expect(zoomOf(content(container))).toBeGreaterThan(1);
104
+ });
105
+
106
+ it('listens for the wheel actively, so it can keep the page from scrolling', () => {
107
+ // A proxy: jsdom does not enforce passive listeners, so a preventDefault in
108
+ // a passive one succeeds here and fails in a browser. React registers its
109
+ // wheel listeners passive; asserting the registration is what can fail.
110
+ const add = vi.spyOn(HTMLElement.prototype, 'addEventListener');
111
+ const { container } = render(
112
+ <Stage
113
+ size={{ width: 100, height: 100 }}
114
+ view={{ zoom: 1, pan: { x: 0, y: 0 } }}
115
+ onViewChange={() => {}}
116
+ >
117
+ <div />
118
+ </Stage>,
119
+ );
120
+ const host = container.querySelector('.lk-stage');
121
+ const wheel = add.mock.calls.filter(
122
+ (call, i) => call[0] === 'wheel' && add.mock.contexts[i] === host,
123
+ );
124
+ expect(wheel.map((call) => call[2])).toContainEqual(
125
+ expect.objectContaining({ passive: false }),
126
+ );
127
+ add.mockRestore();
128
+ });
129
+
130
+ it('tells the surface its tiles moved when the camera does', () => {
131
+ // A transform moves a tile without resizing anything, which is the one
132
+ // kind of move a ResizeObserver never reports.
133
+ const invalidateRects = vi.fn();
134
+ const surface = { invalidateRects } as unknown as SurfaceHandle;
135
+ const at = (view: ViewTransform) => (
136
+ <SurfaceContext.Provider value={surface}>
137
+ <Stage size={{ width: 100, height: 100 }} view={view} onViewChange={() => {}}>
138
+ <div />
139
+ </Stage>
140
+ </SurfaceContext.Provider>
141
+ );
142
+ const { rerender } = render(at({ zoom: 1, pan: { x: 0, y: 0 } }));
143
+ invalidateRects.mockClear();
144
+ rerender(at({ zoom: 1.5, pan: { x: 0, y: 0 } }));
145
+ expect(invalidateRects).toHaveBeenCalled();
146
+ });
147
+ });
148
+
149
+ const staged = defineInstrument<Record<string, never>, Record<string, never>>({
150
+ name: 'Staged',
151
+ defaultConfig: () => ({}),
152
+ initialState: () => ({}),
153
+ render: () => <div data-testid="art" />,
154
+ stage: { size: { width: 240, height: 160 }, initialView: { zoom: 1, pan: { x: 0, y: 0 } } },
155
+ });
156
+
157
+ const plain = defineInstrument<Record<string, never>, Record<string, never>>({
158
+ name: 'Plain',
159
+ defaultConfig: () => ({}),
160
+ initialState: () => ({}),
161
+ render: () => <div data-testid="art" />,
162
+ });
163
+
164
+ describe('an instrument that declares a stage', () => {
165
+ it('renders its DOM on the stage', () => {
166
+ const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
167
+ expect(content(container)).toContainElement(screen.getByTestId('art'));
168
+ });
169
+
170
+ it('gets the zoom controls, and they move the stage', () => {
171
+ const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
172
+ act(() => {
173
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }));
174
+ });
175
+ expect(zoomOf(content(container))).toBeCloseTo(1.25);
176
+ act(() => {
177
+ fireEvent.click(screen.getByRole('button', { name: 'Actual size' }));
178
+ });
179
+ expect(zoomOf(content(container))).toBe(1);
180
+ });
181
+
182
+ it('is what earns them: DOM content without one gets none', () => {
183
+ render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
184
+ expect(screen.queryByRole('button', { name: 'Zoom in' })).toBeNull();
185
+ });
186
+ });
@@ -0,0 +1,134 @@
1
+ import { type CSSProperties, type ReactNode, useContext, useEffect, useRef } from 'react';
2
+ import type { ViewTransform } from '../instrument/types';
3
+ import { normalize2DView } from '../state/view';
4
+ import { useSurfaceOptional } from '../surface/useSurfaceTile';
5
+ import { CameraWheelContext } from './CameraWheelContext';
6
+ import { usePanZoom } from './usePanZoom';
7
+ import type { ViewportSize } from './worldSpec';
8
+
9
+ /** Props for `<Stage>`. */
10
+ export interface StageProps {
11
+ /** The content's own size in CSS pixels, at zoom 1. */
12
+ size: ViewportSize;
13
+ view: ViewTransform;
14
+ onViewChange: (v: ViewTransform) => void;
15
+ minZoom?: number;
16
+ maxZoom?: number;
17
+ /** Fired whenever the stage's viewport is measured at a non-zero size. */
18
+ onResize?: (viewport: ViewportSize) => void;
19
+ /** Handed the viewport element, for a consumer tracking the pointer over it. */
20
+ hostRef?: { current: HTMLDivElement | null };
21
+ /** Drawn over the content in viewport pixels, outside the camera. */
22
+ overlay?: ReactNode;
23
+ children?: ReactNode;
24
+ }
25
+
26
+ /**
27
+ * The camera a stage opens at: the content centered in its viewport, shrunk to
28
+ * fit if it has to be and never enlarged past its own size.
29
+ */
30
+ export function fitStage(size: ViewportSize, viewport: ViewportSize): ViewTransform {
31
+ if (!(size.width > 0 && size.height > 0 && viewport.width > 0 && viewport.height > 0)) {
32
+ return { zoom: 1, pan: { x: 0, y: 0 } };
33
+ }
34
+ const zoom = Math.min(1, viewport.width / size.width, viewport.height / size.height);
35
+ return {
36
+ zoom,
37
+ pan: {
38
+ x: (viewport.width - size.width * zoom) / 2,
39
+ y: (viewport.height - size.height * zoom) / 2,
40
+ },
41
+ };
42
+ }
43
+
44
+ /**
45
+ * DOM content of a fixed size, panned and zoomed by a camera — what
46
+ * `<CanvasStack>` is for layers drawn to a canvas. The content is laid out at
47
+ * its own size and transformed, so everything inside keeps its layout and a
48
+ * measurement of any element in it reports where it is actually drawn.
49
+ */
50
+ export function Stage({
51
+ size,
52
+ view: viewProp,
53
+ onViewChange,
54
+ minZoom,
55
+ maxZoom,
56
+ onResize,
57
+ hostRef,
58
+ overlay,
59
+ children,
60
+ }: StageProps) {
61
+ const view = normalize2DView(viewProp);
62
+ const host = useRef<HTMLDivElement | null>(null);
63
+ const surface = useSurfaceOptional();
64
+ const handlers = usePanZoom({ view: viewProp, onViewChange, minZoom, maxZoom });
65
+ const onWheelRef = useRef(handlers.onWheel);
66
+ onWheelRef.current = handlers.onWheel;
67
+ const wheelSlot = useContext(CameraWheelContext);
68
+
69
+ // Bound by hand because React registers wheel listeners passive, where
70
+ // `preventDefault` is refused and the page scrolls under the zoom.
71
+ useEffect(() => {
72
+ const el = host.current;
73
+ if (!el) return;
74
+ const wheel = (e: WheelEvent): void => onWheelRef.current(e, el);
75
+ el.addEventListener('wheel', wheel, { passive: false });
76
+ if (wheelSlot) wheelSlot.current = wheel;
77
+ return () => {
78
+ el.removeEventListener('wheel', wheel);
79
+ if (wheelSlot?.current === wheel) wheelSlot.current = null;
80
+ };
81
+ }, [wheelSlot]);
82
+
83
+ const onResizeRef = useRef(onResize);
84
+ onResizeRef.current = onResize;
85
+ useEffect(() => {
86
+ const el = host.current;
87
+ if (!el) return;
88
+ const measure = () => {
89
+ const rect = el.getBoundingClientRect();
90
+ if (rect.width > 0 && rect.height > 0) {
91
+ onResizeRef.current?.({ width: rect.width, height: rect.height });
92
+ }
93
+ };
94
+ measure();
95
+ if (typeof ResizeObserver === 'undefined') return;
96
+ const ro = new ResizeObserver(measure);
97
+ ro.observe(el);
98
+ return () => ro.disconnect();
99
+ }, []);
100
+
101
+ // A transform moves every tile inside the content without resizing anything,
102
+ // and a ResizeObserver reports only resizes.
103
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the camera is the trigger, not an input — the effect re-measures whenever it moves.
104
+ useEffect(() => {
105
+ surface?.invalidateRects();
106
+ }, [surface, view.zoom, view.pan.x, view.pan.y]);
107
+
108
+ // The camera changes every frame of a pan, and a transform has nowhere to
109
+ // live but the element's own style: set as custom properties the stylesheet
110
+ // reads.
111
+ const camera = {
112
+ ['--lk-stage-x' as string]: `${view.pan.x}px`,
113
+ ['--lk-stage-y' as string]: `${view.pan.y}px`,
114
+ ['--lk-stage-zoom' as string]: String(view.zoom),
115
+ ['--lk-stage-w' as string]: `${size.width}px`,
116
+ ['--lk-stage-h' as string]: `${size.height}px`,
117
+ } as CSSProperties;
118
+
119
+ return (
120
+ <div
121
+ ref={(el) => {
122
+ host.current = el;
123
+ if (hostRef) hostRef.current = el;
124
+ }}
125
+ className="lk-stage"
126
+ onPointerDown={handlers.onPointerDown}
127
+ >
128
+ <div className="lk-stage__content" style={camera}>
129
+ {children}
130
+ </div>
131
+ {overlay}
132
+ </div>
133
+ );
134
+ }
@@ -41,11 +41,18 @@ describe('zoomAt', () => {
41
41
  expect(screenToWorld(at, next).x).toBeCloseTo(before.x, 10);
42
42
  });
43
43
 
44
- it('treats a non-finite zoom as 1 rather than propagating NaN', () => {
44
+ it('keeps a view that arrived with a non-finite zoom finite', () => {
45
45
  const next = zoomAt({ zoom: Number.NaN, pan: { x: 0, y: 0 } }, 2, { x: 10, y: 10 });
46
- expect(next.zoom).toBe(2);
46
+ expect(next.zoom > 0 && Number.isFinite(next.zoom)).toBe(true);
47
47
  expect(Number.isFinite(next.pan.x)).toBe(true);
48
48
  });
49
+
50
+ it('keeps the zoom positive when a factor and bound would drive it to 0', () => {
51
+ const at = { x: 10, y: 10 };
52
+ const next = zoomAt(view, 0, at, { min: 0 });
53
+ expect(next.zoom > 0 && Number.isFinite(next.zoom)).toBe(true);
54
+ expect(Number.isFinite(screenToWorld(at, next).x)).toBe(true);
55
+ });
49
56
  });
50
57
 
51
58
  describe('centerOn', () => {
@@ -76,3 +83,11 @@ describe('centerOn', () => {
76
83
  expect(centerOn({ x: 0, y: 0 }, 6, { width: 10, height: 10 }, DEFAULT_FRAME).zoom).toBe(6);
77
84
  });
78
85
  });
86
+
87
+ describe('centerOn zoom invariant', () => {
88
+ it('builds a camera with a positive zoom from a zoom of 0', () => {
89
+ const v = centerOn({ x: 1, y: 1 }, 0, { width: 100, height: 100 });
90
+ expect(v.zoom > 0 && Number.isFinite(v.zoom)).toBe(true);
91
+ expect(Number.isFinite(screenToWorld({ x: 5, y: 5 }, v).x)).toBe(true);
92
+ });
93
+ });
@@ -1,3 +1,4 @@
1
+ import { normalizeZoom } from '@weasel-js/core';
1
2
  import type { Point, ViewTransform } from '../instrument/types';
2
3
  import { DEFAULT_FRAME, type ViewportSize, type WorldFrame } from './worldSpec';
3
4
 
@@ -10,10 +11,6 @@ export interface ZoomAtOptions {
10
11
  max?: number;
11
12
  }
12
13
 
13
- function usableZoom(zoom: number): number {
14
- return Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
15
- }
16
-
17
14
  /**
18
15
  * Zoom a view by `factor` about a fixed point, so whatever world coordinate
19
16
  * `at` was over stays under it.
@@ -32,8 +29,10 @@ export function zoomAt(
32
29
  // drifts by `(1 - ratio) * originPx` per step on any frame that moves it.
33
30
  const anchorX = at.x - frame.originPx.x;
34
31
  const anchorY = at.y - frame.originPx.y;
35
- const current = usableZoom(view.zoom);
36
- const zoom = Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? 0, current * factor));
32
+ const current = normalizeZoom(view.zoom);
33
+ const zoom = normalizeZoom(
34
+ Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? 0, current * factor)),
35
+ );
37
36
  const ratio = zoom / current;
38
37
  return {
39
38
  zoom,
@@ -52,11 +51,12 @@ export function centerOn(
52
51
  size: ViewportSize,
53
52
  frame: WorldFrame = DEFAULT_FRAME,
54
53
  ): ViewTransform {
54
+ const z = normalizeZoom(zoom);
55
55
  return {
56
- zoom,
56
+ zoom: z,
57
57
  pan: {
58
- x: size.width / 2 - frame.originPx.x - world.x * zoom,
59
- y: size.height / 2 - frame.originPx.y - world.y * zoom * frame.yDir,
58
+ x: size.width / 2 - frame.originPx.x - world.x * z,
59
+ y: size.height / 2 - frame.originPx.y - world.y * z * frame.yDir,
60
60
  },
61
61
  };
62
62
  }
@@ -1,3 +1,5 @@
1
+ export type { CameraWheelSlot } from './CameraWheelContext';
2
+ export { CameraWheelContext } from './CameraWheelContext';
1
3
  export type { CanvasStackProps } from './CanvasStack';
2
4
  export { CanvasStack } from './CanvasStack';
3
5
  export type { CanvasStackContextValue, CanvasStackSurface } from './CanvasStackContext';
@@ -5,6 +7,8 @@ export { CanvasStackContext } from './CanvasStackContext';
5
7
  export type { ZoomAtOptions } from './camera';
6
8
  export { centerOn, zoomAt } from './camera';
7
9
  export { screenToWorld, worldToScreen } from './canvasCoords';
10
+ export type { StageProps } from './Stage';
11
+ export { fitStage, Stage } from './Stage';
8
12
  export type { CanvasLayerDescriptor } from './useLayerScheduler';
9
13
  export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './useOrbit';
10
14
  export {
@@ -7,6 +7,7 @@ import {
7
7
  type WheelEvent,
8
8
  } from 'react';
9
9
  import type { ViewTransform } from '../instrument/types';
10
+ import { normalize2DView } from '../state/view';
10
11
  import { zoomAt } from './camera';
11
12
  import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
12
13
 
@@ -28,7 +29,9 @@ export interface UsePanZoomOptions {
28
29
  }
29
30
 
30
31
  export interface PanZoomHandlers {
31
- onWheel: (e: WheelEvent<HTMLElement>) => void;
32
+ /** Zoom about the pointer. `anchor` is the element the pointer position is
33
+ * measured against, for an event whose `currentTarget` is some other box. */
34
+ onWheel: (e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => void;
32
35
  onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
33
36
  isDragging: () => boolean;
34
37
  }
@@ -56,8 +59,8 @@ export function usePanZoom({
56
59
  }: UsePanZoomOptions): PanZoomHandlers {
57
60
  const dragRef = useRef<DragState | null>(null);
58
61
  const sessionRef = useRef<PointerSession | null>(null);
59
- const viewRef = useRef(view);
60
- viewRef.current = view;
62
+ const viewRef = useRef(normalize2DView(view));
63
+ viewRef.current = normalize2DView(view);
61
64
  const onViewChangeRef = useRef(onViewChange);
62
65
  onViewChangeRef.current = onViewChange;
63
66
  const onTapRef = useRef(onTap);
@@ -68,9 +71,11 @@ export function usePanZoom({
68
71
  const initialZoomRef = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
69
72
 
70
73
  const onWheel = useCallback(
71
- (e: WheelEvent<HTMLElement>) => {
74
+ (e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => {
75
+ const el = anchor ?? (e.currentTarget as Element | null);
76
+ if (!el) return;
72
77
  e.preventDefault();
73
- const rect = e.currentTarget.getBoundingClientRect();
78
+ const rect = el.getBoundingClientRect();
74
79
  const initialZoom = initialZoomRef.current;
75
80
  onViewChange(
76
81
  zoomAt(
@@ -76,7 +76,6 @@ export const EveryRegion: Story = {
76
76
  instruments: [FullInstrument],
77
77
  defaultInstrument: 'Every Region',
78
78
  title: 'Chrome Regions',
79
- storage: null,
80
79
  },
81
80
  };
82
81
 
@@ -86,7 +85,6 @@ export const SuppressedAndReplaced: Story = {
86
85
  instruments: [FullInstrument],
87
86
  defaultInstrument: 'Every Region',
88
87
  title: 'Suppress + Replace',
89
- storage: null,
90
88
  suppress: ['snapshot'],
91
89
  chrome: [
92
90
  {
@@ -116,7 +114,6 @@ export const SubjectTitleAndLead: Story = {
116
114
  instruments: [FullInstrument],
117
115
  defaultInstrument: 'Every Region',
118
116
  title: 'Title Bar Lead',
119
- storage: null,
120
117
  suppress: ['snapshot'],
121
118
  chrome: [
122
119
  {
@@ -0,0 +1,114 @@
1
+ import { useContext, useMemo } from 'react';
2
+ import { useStore } from 'zustand/react';
3
+ import { useLabContext } from '../lab/LabContext';
4
+ import { LabStoreContext } from '../state/context';
5
+ import { usePersistedState } from '../state/usePersistedState';
6
+ import { resolveLabTool } from '../tools/labTool';
7
+ import type { TrialTool } from '../tools/types';
8
+ import type { LabChromeContext, LabContribution, LabRegion } from './labTypes';
9
+ import { mergeContributions } from './merge';
10
+ import { SidebarRegion } from './regions/SidebarRegion';
11
+ import { StatusRegion } from './regions/StatusRegion';
12
+ import { ToolbarRegion } from './regions/ToolbarRegion';
13
+
14
+ /** The lab's chrome context: the lab itself, plus the lab's tool slot. Throws
15
+ * outside a `<Lab>`. */
16
+ export function useLabChromeContext(): LabChromeContext {
17
+ const lab = useLabContext();
18
+ const storeCtx = useContext(LabStoreContext);
19
+ if (!storeCtx) throw new Error('[labkit] lab chrome requires <LabStoreProvider>');
20
+ const slot = useStore(storeCtx.store, (s) => s.activeToolId);
21
+ const storeInstruments = useStore(storeCtx.store, (s) => s.instruments);
22
+ const activeToolId = resolveLabTool(slot, storeInstruments ?? lab.instruments);
23
+ const setLabTool = useStore(storeCtx.store, (s) => s.setLabTool);
24
+ return useMemo(
25
+ () => ({ ...lab, activeToolId, setActiveTool: setLabTool }),
26
+ [lab, activeToolId, setLabTool],
27
+ );
28
+ }
29
+
30
+ /** The lab's contributions for one region, in declaration order. */
31
+ export function contributionsIn<TCtx = LabChromeContext>(
32
+ contributions: readonly LabContribution<TCtx>[],
33
+ region: LabRegion,
34
+ ): LabContribution<TCtx>[] {
35
+ return contributions.filter((c) => c.region === region);
36
+ }
37
+
38
+ /** The palette contributions a lab's `tools` stand for. Declaring a tool is
39
+ * shorthand for contributing a palette item that writes the lab's tool
40
+ * slot. */
41
+ export function toolContributions(tools: readonly TrialTool[]): LabContribution[] {
42
+ return tools.map((t) => ({
43
+ id: t.id,
44
+ region: 'palette' as const,
45
+ ...(t.group === undefined ? {} : { group: t.group }),
46
+ item: {
47
+ icon: t.icon,
48
+ label: t.label,
49
+ ...(t.shortcut === undefined ? {} : { shortcut: t.shortcut }),
50
+ },
51
+ }));
52
+ }
53
+
54
+ /** Everything the lab's chrome renders, from both of the ways a lab declares
55
+ * it. Tools and contributions share one id namespace, so a tool colliding
56
+ * with a contribution throws here rather than one of them silently losing. */
57
+ export function labContributions(
58
+ tools: readonly TrialTool[] | undefined,
59
+ chrome: readonly LabContribution[] | undefined,
60
+ ): LabContribution[] {
61
+ return mergeContributions<LabContribution>(toolContributions(tools ?? []), [...(chrome ?? [])]);
62
+ }
63
+
64
+ /** Props shared by the lab's region mounts. Each takes the lab's whole
65
+ * contribution list and lays out the part addressed to it. */
66
+ export interface LabRegionProps {
67
+ contributions: readonly LabContribution[];
68
+ }
69
+
70
+ /** The lab's action bar, in the shell header beside the title. */
71
+ export function LabHeaderRegion({ contributions }: LabRegionProps) {
72
+ const ctx = useLabChromeContext();
73
+ return (
74
+ <ToolbarRegion
75
+ region="header"
76
+ label="Lab actions"
77
+ contributions={contributionsIn(contributions, 'header')}
78
+ ctx={ctx}
79
+ />
80
+ );
81
+ }
82
+
83
+ /** The lab's sidebar sections. Fold state is a lab-scoped persisted value, so it
84
+ * survives a reload in a stored lab and lasts the session in an unstored one. */
85
+ export function LabSidebarRegion({ contributions }: LabRegionProps) {
86
+ const lab = useLabChromeContext();
87
+ const [collapsedSections, setFolds] = usePersistedState<Record<string, boolean>>(
88
+ 'lk-lab-sidebar-folds',
89
+ {},
90
+ { scope: 'lab' },
91
+ );
92
+ const ctx = useMemo(
93
+ () => ({
94
+ ...lab,
95
+ collapsedSections,
96
+ setSectionCollapsed: (key: string, collapsed: boolean) =>
97
+ setFolds((f) => ({ ...f, [key]: collapsed })),
98
+ }),
99
+ [lab, collapsedSections, setFolds],
100
+ );
101
+ return <SidebarRegion contributions={contributionsIn(contributions, 'sidebar')} ctx={ctx} />;
102
+ }
103
+
104
+ /** The lab's readouts, in the shell footer below the workspace. */
105
+ export function LabFooterRegion({ contributions }: LabRegionProps) {
106
+ const ctx = useLabChromeContext();
107
+ return (
108
+ <StatusRegion
109
+ region="footer"
110
+ contributions={contributionsIn(contributions, 'footer')}
111
+ ctx={ctx}
112
+ />
113
+ );
114
+ }