@weasel-js/labkit 1.2.0 → 1.3.0-pre.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/dist/_dts/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
  2. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  3. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  4. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  5. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  6. package/dist/_dts/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
  7. package/dist/canvas/index.d.ts +7 -3
  8. package/dist/canvas/index.js +2 -1
  9. package/dist/chrome/index.d.ts +86 -0
  10. package/dist/chrome/index.js +8 -0
  11. package/dist/chrome/index.js.map +1 -0
  12. package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
  13. package/dist/chunk-4TMMVIDM.js.map +1 -0
  14. package/dist/{chunk-THBG7FQZ.js → chunk-HFDVGF4X.js} +15 -12
  15. package/dist/chunk-HFDVGF4X.js.map +1 -0
  16. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  17. package/dist/chunk-NS54R52R.js.map +1 -0
  18. package/dist/{chunk-G5TJVQQT.js → chunk-O2BULFHX.js} +5 -14
  19. package/dist/chunk-O2BULFHX.js.map +1 -0
  20. package/dist/chunk-O7NVSCLN.js +266 -0
  21. package/dist/chunk-O7NVSCLN.js.map +1 -0
  22. package/dist/chunk-PO6L3NSH.js +366 -0
  23. package/dist/chunk-PO6L3NSH.js.map +1 -0
  24. package/dist/chunk-PQJ5B2U2.js +27 -0
  25. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  26. package/dist/chunk-RVNN4CHQ.js +212 -0
  27. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  28. package/dist/chunk-TLGRYALP.js +6151 -0
  29. package/dist/chunk-TLGRYALP.js.map +1 -0
  30. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  31. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  32. package/dist/{chunk-BOHF3PQO.js → chunk-Z6HJ5FEM.js} +8111 -5190
  33. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  34. package/dist/chunk-ZZAYVX4X.js +505 -0
  35. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  36. package/dist/controls/index.d.ts +19 -5
  37. package/dist/controls/index.js +3 -1
  38. package/dist/dragdrop/index.d.ts +4 -2
  39. package/dist/index.d.ts +246 -106
  40. package/dist/index.js +673 -559
  41. package/dist/index.js.map +1 -1
  42. package/dist/layers/index.d.ts +5 -3
  43. package/dist/layers/index.js +2 -1
  44. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  45. package/dist/passthrough/weasel-canvas.js +1 -1
  46. package/dist/passthrough/weasel-ui.d.ts +3799 -3672
  47. package/dist/passthrough/weasel-ui.js +2 -2
  48. package/dist/primitives/index.d.ts +102 -8
  49. package/dist/primitives/index.js +4 -1
  50. package/dist/state/index.d.ts +3 -2
  51. package/dist/state/index.js +2 -2
  52. package/dist/styles.css +542 -153
  53. package/dist/surface/index.js +2 -1
  54. package/dist/ui/layers/index.js +4 -3
  55. package/dist/undo/index.d.ts +4 -2
  56. package/package.json +16 -7
  57. package/src/canvas/AGENTS.md +4 -0
  58. package/src/canvas/CanvasStack.tsx +6 -2
  59. package/src/canvas/useLayerScheduler.ts +22 -15
  60. package/src/canvas/usePanZoom.test.ts +80 -0
  61. package/src/canvas/usePanZoom.ts +16 -2
  62. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  63. package/src/chrome/builtins.test.ts +153 -0
  64. package/src/chrome/builtins.tsx +205 -0
  65. package/src/chrome/index.ts +25 -0
  66. package/src/chrome/merge.test.ts +51 -0
  67. package/src/chrome/merge.ts +52 -0
  68. package/src/chrome/regions/PaletteRegion.less +14 -0
  69. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  70. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  71. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  72. package/src/chrome/regions/StatusRegion.tsx +31 -0
  73. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  74. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  75. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  76. package/src/chrome/regions/ViewportRegion.less +42 -0
  77. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  78. package/src/chrome/regions/regions.test.tsx +81 -0
  79. package/src/chrome/types.ts +127 -0
  80. package/src/config/builder.test.ts +87 -0
  81. package/src/config/builder.ts +224 -0
  82. package/src/config/fromConfigField.test.ts +93 -0
  83. package/src/config/fromConfigField.ts +74 -0
  84. package/src/config/index.ts +22 -0
  85. package/src/config/resolve.test.ts +120 -0
  86. package/src/config/resolve.ts +67 -0
  87. package/src/config/rules.ts +68 -0
  88. package/src/config/types.ts +108 -0
  89. package/src/config/useConfigSchema.ts +30 -0
  90. package/src/config/visible.ts +20 -0
  91. package/src/controls/ControlPanel.less +4 -58
  92. package/src/controls/ControlPanel.stories.tsx +86 -0
  93. package/src/controls/ControlPanel.test.tsx +223 -10
  94. package/src/controls/ControlPanel.tsx +202 -203
  95. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  96. package/src/dragdrop/DragGhost.less +1 -1
  97. package/src/dragdrop/Palette.less +1 -1
  98. package/src/index.ts +25 -3
  99. package/src/instrument/SineWave.smoke.test.tsx +8 -1
  100. package/src/instrument/defineInstrument.test.ts +38 -1
  101. package/src/instrument/defineInstrument.ts +31 -4
  102. package/src/instrument/index.ts +1 -0
  103. package/src/instrument/types.ts +27 -4
  104. package/src/instrument/validateConfigSchema.test.ts +13 -3
  105. package/src/instrument/validateConfigSchema.ts +7 -10
  106. package/src/lab/Lab.chrome.test.tsx +65 -0
  107. package/src/lab/Lab.tsx +58 -15
  108. package/src/lab/LabContext.ts +6 -0
  109. package/src/lab/LabFullChrome.stories.tsx +308 -0
  110. package/src/lab/LabHeader.test.tsx +42 -0
  111. package/src/lab/LabHeader.tsx +57 -0
  112. package/src/lab/LabPalette.test.tsx +35 -0
  113. package/src/lab/LabPalette.tsx +34 -0
  114. package/src/lab/LabShell.less +50 -3
  115. package/src/lab/Workspace.less +1 -30
  116. package/src/lab/Workspace.test.tsx +20 -10
  117. package/src/lab/Workspace.tsx +5 -8
  118. package/src/lab/index.ts +2 -0
  119. package/src/layers/AGENTS.md +1 -1
  120. package/src/layers/LayerList.less +31 -7
  121. package/src/layers/LayerList.tsx +17 -4
  122. package/src/passthrough/weasel-ui.ts +23 -0
  123. package/src/primitives/DragHandleGlyph.tsx +29 -0
  124. package/src/primitives/FloatingPanel.less +28 -0
  125. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  126. package/src/primitives/FloatingPanel.test.tsx +240 -0
  127. package/src/primitives/FloatingPanel.tsx +190 -0
  128. package/src/primitives/FpsMeter.test.tsx +44 -0
  129. package/src/primitives/FpsMeter.tsx +20 -9
  130. package/src/primitives/JobProgress.less +69 -0
  131. package/src/primitives/JobProgress.stories.tsx +48 -0
  132. package/src/primitives/JobProgress.tsx +65 -0
  133. package/src/primitives/Legend.less +47 -0
  134. package/src/primitives/Legend.stories.tsx +32 -0
  135. package/src/primitives/Legend.test.tsx +57 -0
  136. package/src/primitives/Legend.tsx +40 -0
  137. package/src/primitives/Sidebar.less +32 -2
  138. package/src/primitives/StatusBar.less +7 -0
  139. package/src/primitives/StatusBar.tsx +10 -3
  140. package/src/primitives/Toolbar.less +50 -3
  141. package/src/primitives/Toolbar.test.tsx +67 -1
  142. package/src/primitives/Toolbar.tsx +72 -6
  143. package/src/primitives/ZoomControl.less +31 -0
  144. package/src/primitives/ZoomControl.tsx +76 -0
  145. package/src/primitives/floating.entry.test.tsx +14 -0
  146. package/src/primitives/index.ts +10 -2
  147. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  148. package/src/primitives/useRovingTabIndex.ts +79 -0
  149. package/src/state/store.ts +11 -3
  150. package/src/state/toolSlot.test.ts +36 -0
  151. package/src/state/types.ts +4 -0
  152. package/src/styles.less +6 -0
  153. package/src/surface/useTiledSurface.ts +14 -10
  154. package/src/test-setup.ts +26 -0
  155. package/src/theme/base.less +19 -2
  156. package/src/theme/interstellar.tokens.json +6 -2
  157. package/src/tools/types.ts +24 -0
  158. package/src/trial/Trial.config.test.tsx +113 -0
  159. package/src/trial/Trial.job.test.tsx +13 -15
  160. package/src/trial/Trial.less +115 -10
  161. package/src/trial/Trial.stories.tsx +1 -0
  162. package/src/trial/Trial.test.tsx +67 -96
  163. package/src/trial/Trial.tsx +80 -39
  164. package/src/trial/TrialChrome.tsx +99 -80
  165. package/src/trial/TrialDragContext.ts +10 -0
  166. package/src/trial/TrialTitleBar.tsx +41 -0
  167. package/src/trial/index.ts +1 -15
  168. package/src/trial/trialChrome.borders.test.ts +38 -0
  169. package/src/ui/layers/LayerStack.less +9 -9
  170. package/src/ui/layers/LayerStack.tsx +1 -13
  171. package/src/ui/properties/CurveField.less +2 -2
  172. package/src/ui/properties/CurveField.test.tsx +11 -11
  173. package/src/ui/properties/PropertyGroup.less +2 -2
  174. package/src/ui/properties/PropertyPanel.less +55 -41
  175. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  176. package/src/ui/properties/storyLayouts.tsx +2 -2
  177. package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
  178. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  179. package/dist/chunk-574LJAV4.js.map +0 -1
  180. package/dist/chunk-5R2ATYPJ.js +0 -201
  181. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  182. package/dist/chunk-73KA7WBO.js +0 -5992
  183. package/dist/chunk-73KA7WBO.js.map +0 -1
  184. package/dist/chunk-BOHF3PQO.js.map +0 -1
  185. package/dist/chunk-G5TJVQQT.js.map +0 -1
  186. package/dist/chunk-PMAU3SEE.js +0 -126
  187. package/dist/chunk-PMAU3SEE.js.map +0 -1
  188. package/dist/chunk-SFL7NFKN.js.map +0 -1
  189. package/dist/chunk-THBG7FQZ.js.map +0 -1
  190. package/dist/chunk-VUU5UXHE.js.map +0 -1
  191. package/src/instrument/capabilityDetector.test.ts +0 -64
  192. package/src/instrument/capabilityDetector.ts +0 -20
  193. package/src/trial/DefaultSidebar.tsx +0 -29
  194. package/src/trial/DefaultStatusBar.tsx +0 -19
  195. package/src/trial/DefaultToolbar.tsx +0 -92
  196. package/src/trial/slotTypes.ts +0 -54
@@ -1,3 +1,4 @@
1
- export { SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceOptional, useSurfaceTile, useTiledSurface } from '../chunk-THBG7FQZ.js';
1
+ export { SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceOptional, useSurfaceTile, useTiledSurface } from '../chunk-HFDVGF4X.js';
2
+ import '../chunk-Z6HJ5FEM.js';
2
3
  //# sourceMappingURL=index.js.map
3
4
  //# sourceMappingURL=index.js.map
@@ -1,5 +1,6 @@
1
- export { LayerStack } from '../../chunk-G5TJVQQT.js';
2
- import '../../chunk-73KA7WBO.js';
3
- import '../../chunk-BOHF3PQO.js';
1
+ export { LayerStack } from '../../chunk-O2BULFHX.js';
2
+ import '../../chunk-TLGRYALP.js';
3
+ import '../../chunk-Z6HJ5FEM.js';
4
+ import '../../chunk-PQJ5B2U2.js';
4
5
  //# sourceMappingURL=index.js.map
5
6
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,8 @@
1
- export { U as UndoCapability } from '../_dts/index-JFAYj5Tv.js';
1
+ export { U as UndoCapability } from '../_dts/index-DgcNdEdh.js';
2
2
  import 'react';
3
- import '../_dts/types-x92Kfeme.js';
3
+ import '../_dts/types-AHlQxBNN.js';
4
+ import '../_dts/PrefsForm-BYa6cWnO.js';
5
+ import '../_dts/types-1Sdxy_Pv.js';
4
6
  import '../_dts/types-DJ79Tg5J.js';
5
7
 
6
8
  /** A subscriber to a named event. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.2.0",
3
+ "version": "1.3.0-pre.0",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -14,6 +14,9 @@
14
14
  "url": "https://github.com/orochi235/weasel/issues"
15
15
  },
16
16
  "type": "module",
17
+ "sideEffects": [
18
+ "*.css"
19
+ ],
17
20
  "engines": {
18
21
  "node": ">=22"
19
22
  },
@@ -27,6 +30,10 @@
27
30
  "types": "./dist/index.d.ts",
28
31
  "import": "./dist/index.js"
29
32
  },
33
+ "./chrome": {
34
+ "types": "./dist/chrome/index.d.ts",
35
+ "import": "./dist/chrome/index.js"
36
+ },
30
37
  "./primitives": {
31
38
  "types": "./dist/primitives/index.d.ts",
32
39
  "import": "./dist/primitives/index.js"
@@ -89,31 +96,33 @@
89
96
  "test": "vitest run",
90
97
  "test:watch": "vitest",
91
98
  "test:smoke:consumer": "node scripts/smoke-consumer-bundle.mjs",
92
- "lint": "biome check . && tsx scripts/check-class-prefix.ts",
99
+ "lint": "biome check . && tsx scripts/check-class-prefix.ts && tsx scripts/check-design-tokens.ts",
93
100
  "lint:fix": "biome check --write .",
94
101
  "format": "biome format --write .",
95
102
  "docs:dev": "vitepress dev docs",
96
103
  "docs:build": "vitepress build docs",
97
- "docs:preview": "vitepress preview docs"
104
+ "docs:preview": "vitepress preview docs",
105
+ "dev:schema": "LABKIT_EXAMPLE=schema-lab vite"
98
106
  },
99
107
  "peerDependencies": {
100
108
  "react": "^19.0.0",
101
109
  "react-dom": "^19.0.0"
102
110
  },
103
111
  "dependencies": {
104
- "@weasel-js/theme": "*",
112
+ "@weasel-js/core": "1.3.0-pre.0",
113
+ "@weasel-js/theme": "1.3.0-pre.0",
114
+ "@weasel-js/ui": "1.3.0-pre.0",
105
115
  "earcut": "2.2.4",
106
116
  "polygon-clipping": "^0.15.7",
107
117
  "react-aria-components": "^1.5.0",
108
- "windease": "^1.2.1",
118
+ "windease": "^1.3.0",
109
119
  "zustand": "^5.0.12"
110
120
  },
111
121
  "devDependencies": {
112
122
  "@biomejs/biome": "^2.4.12",
113
123
  "@rollup/plugin-alias": "^6.0.0",
114
124
  "@testing-library/jest-dom": "^6.6.0",
115
- "@weasel-js/modes": "*",
116
- "@weasel-js/ui": "*",
125
+ "@weasel-js/modes": "1.3.0-pre.0",
117
126
  "less": "^4.2.0",
118
127
  "rollup-plugin-dts": "^6.4.1",
119
128
  "tsx": "^4.19.0",
@@ -28,6 +28,8 @@ interface CanvasStackProps {
28
28
  layers: CanvasLayerDescriptor[]; // { id, visible, render(ctx, view) }
29
29
  view: ViewTransform; // { zoom, pan: { x, y } }
30
30
  onViewChange: (v: ViewTransform) => void;
31
+ minZoom?: number; // default 0.1, forwarded to usePanZoom
32
+ maxZoom?: number; // default 32, forwarded to usePanZoom
31
33
  width?: number | string; // default '100%'
32
34
  height?: number | string; // default '100%'
33
35
  className?: string;
@@ -79,6 +81,8 @@ Both are pure. Pan is applied in screen space; zoom multiplies world coordinates
79
81
 
80
82
  Implemented in `usePanZoom`. Mouse-wheel zoom is anchored at the cursor; primary-button drag pans. `isDragging()` is exposed so consumers can suppress click handlers during a pan.
81
83
 
84
+ Zoom is clamped to `[minZoom, maxZoom]` (defaults 0.1 / 32), settable via `CanvasStack`'s props and, for an instrument's own canvas, `CanvasCapability.minZoom` / `maxZoom`. The clamp always widens to admit whatever zoom the canvas opened at — an instrument declaring `initialView.zoom: 1600` with the default range is not clamped down to 32 on the first wheel event.
85
+
82
86
  ## Testing notes
83
87
 
84
88
  JSDOM does not provide a real `CanvasRenderingContext2D`. Tests in `CanvasStack.test.tsx` rely on the scheduler's defensive checks; they do not assert pixels. For pixel testing, use Storybook's `chromatic` or visual snapshots in a real browser.
@@ -10,6 +10,8 @@ export interface CanvasStackProps {
10
10
  layers: CanvasLayerDescriptor[];
11
11
  view: ViewTransform;
12
12
  onViewChange: (v: ViewTransform) => void;
13
+ minZoom?: number;
14
+ maxZoom?: number;
13
15
  width?: number | string;
14
16
  height?: number | string;
15
17
  className?: string;
@@ -24,6 +26,8 @@ export function CanvasStack({
24
26
  layers,
25
27
  view,
26
28
  onViewChange,
29
+ minZoom,
30
+ maxZoom,
27
31
  width = '100%',
28
32
  height = '100%',
29
33
  className,
@@ -59,8 +63,8 @@ export function CanvasStack({
59
63
  return () => ro.disconnect();
60
64
  }, []);
61
65
 
62
- const handlers = usePanZoom({ view, onViewChange });
63
- useLayerScheduler({ layers, view, canvasRefs: canvasMap, size });
66
+ const handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom });
67
+ useLayerScheduler({ layers, view, canvasRefs: canvasMap, size, host: containerRef });
64
68
 
65
69
  const ctxValue = useMemo(() => ({ view }), [view]);
66
70
 
@@ -1,3 +1,4 @@
1
+ import { useVisibleRaf } from '@weasel-js/core';
1
2
  import { type RefObject, useEffect, useRef } from 'react';
2
3
  import type { ViewTransform } from '../instrument/types';
3
4
 
@@ -14,11 +15,19 @@ interface SchedulerOptions {
14
15
  view: ViewTransform;
15
16
  canvasRefs: RefObject<Map<string, HTMLCanvasElement>>;
16
17
  size: { width: number; height: number; dpr: number };
18
+ /** The element the stack occupies. Given one, the scheduler also stops while
19
+ * the stack sits outside the viewport. */
20
+ host?: RefObject<Element | null>;
17
21
  }
18
22
 
19
- export function useLayerScheduler({ layers, view, canvasRefs, size }: SchedulerOptions): void {
23
+ export function useLayerScheduler({
24
+ layers,
25
+ view,
26
+ canvasRefs,
27
+ size,
28
+ host,
29
+ }: SchedulerOptions): void {
20
30
  const dirty = useRef<Set<string>>(new Set());
21
- const rafId = useRef<number | null>(null);
22
31
  const lastRenderRef = useRef<Map<string, CanvasLayerDescriptor['render']>>(new Map());
23
32
 
24
33
  useEffect(() => {
@@ -37,9 +46,11 @@ export function useLayerScheduler({ layers, view, canvasRefs, size }: SchedulerO
37
46
  for (const layer of layers) dirty.current.add(layer.id);
38
47
  }, [view, size, layers]);
39
48
 
40
- useEffect(() => {
41
- const tick = () => {
42
- rafId.current = null;
49
+ // Painting only what is dirty is not the same as doing nothing: a hidden tab
50
+ // still commits React updates, and the effect above marks every layer dirty
51
+ // on any view or size change. The gate is what actually stops the work.
52
+ const frameLoop = useVisibleRaf(
53
+ () => {
43
54
  if (dirty.current.size === 0) return;
44
55
  const map = canvasRefs.current;
45
56
  if (!map) {
@@ -60,15 +71,11 @@ export function useLayerScheduler({ layers, view, canvasRefs, size }: SchedulerO
60
71
  ctx.restore();
61
72
  }
62
73
  dirty.current.clear();
63
- };
64
- if (dirty.current.size > 0 && rafId.current === null) {
65
- rafId.current = requestAnimationFrame(tick);
66
- }
67
- return () => {
68
- if (rafId.current !== null) {
69
- cancelAnimationFrame(rafId.current);
70
- rafId.current = null;
71
- }
72
- };
74
+ },
75
+ { target: host },
76
+ );
77
+
78
+ useEffect(() => {
79
+ if (dirty.current.size > 0) frameLoop.request();
73
80
  });
74
81
  }
@@ -0,0 +1,80 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import type { WheelEvent } from 'react';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { ViewTransform } from '../instrument/types';
5
+ import { type UsePanZoomOptions, usePanZoom } from './usePanZoom';
6
+
7
+ function wheelEvent(deltaY: number): WheelEvent<HTMLElement> {
8
+ return {
9
+ deltaY,
10
+ clientX: 0,
11
+ clientY: 0,
12
+ preventDefault: () => {},
13
+ currentTarget: { getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }) },
14
+ } as unknown as WheelEvent<HTMLElement>;
15
+ }
16
+
17
+ /** Renders the hook the way `CanvasStack` drives it: `view` lives in the
18
+ * test, `onViewChange` writes it, and each wheel call re-renders the hook
19
+ * with the updated view before returning it. */
20
+ function setup(initialView: ViewTransform, options?: Partial<UsePanZoomOptions>) {
21
+ let view = initialView;
22
+ const onViewChange = (v: ViewTransform) => {
23
+ view = v;
24
+ };
25
+ const { result, rerender } = renderHook(
26
+ (props: { view: ViewTransform }) => usePanZoom({ ...options, view: props.view, onViewChange }),
27
+ { initialProps: { view: initialView } },
28
+ );
29
+ const wheel = (deltaY: number): ViewTransform => {
30
+ act(() => result.current.onWheel(wheelEvent(deltaY)));
31
+ rerender({ view });
32
+ return view;
33
+ };
34
+ return { wheel, getView: () => view };
35
+ }
36
+
37
+ describe('usePanZoom', () => {
38
+ it('does not collapse a canvas opening far outside the default range on the first wheel event', () => {
39
+ const { wheel } = setup({ zoom: 1600, pan: { x: 0, y: 0 } });
40
+ const next = wheel(-1); // zoom in slightly
41
+ expect(next.zoom).toBeCloseTo(1600, 0);
42
+ });
43
+
44
+ it('keeps the opening zoom reachable after zooming away from it', () => {
45
+ const { wheel } = setup({ zoom: 1600, pan: { x: 0, y: 0 } });
46
+ // Zoom far out.
47
+ for (let i = 0; i < 20; i++) wheel(1000);
48
+ const zoomedOut = wheel(1000);
49
+ expect(zoomedOut.zoom).toBeLessThan(1600);
50
+ // Zoom back in — the opening view must still be reachable.
51
+ for (let i = 0; i < 40; i++) wheel(-1000);
52
+ const zoomedIn = wheel(-1000);
53
+ expect(zoomedIn.zoom).toBeCloseTo(1600, 0);
54
+ });
55
+
56
+ it('admits the opening zoom even when an explicit maxZoom is lower than it', () => {
57
+ const { wheel } = setup({ zoom: 1600, pan: { x: 0, y: 0 } }, { maxZoom: 32 });
58
+ const next = wheel(-1);
59
+ expect(next.zoom).toBeCloseTo(1600, 0);
60
+ });
61
+
62
+ it('falls back to the plain defaults when the opening zoom is non-finite or zero', () => {
63
+ const nanCase = setup({ zoom: Number.NaN, pan: { x: 0, y: 0 } });
64
+ const nanResult = nanCase.wheel(-100000);
65
+ expect(nanResult.zoom).toBeCloseTo(32, 5);
66
+ expect(Number.isNaN(nanResult.zoom)).toBe(false);
67
+
68
+ const zeroCase = setup({ zoom: 0, pan: { x: 0, y: 0 } });
69
+ const zeroResult = zeroCase.wheel(100000);
70
+ expect(zeroResult.zoom).toBeCloseTo(0.1, 5);
71
+ });
72
+
73
+ it('still clamps ordinary views to the default range', () => {
74
+ const { wheel } = setup({ zoom: 1, pan: { x: 0, y: 0 } });
75
+ const zoomedIn = wheel(-1_000_000);
76
+ expect(zoomedIn.zoom).toBeCloseTo(32, 5);
77
+ const zoomedOut = wheel(1_000_000);
78
+ expect(zoomedOut.zoom).toBeCloseTo(0.1, 5);
79
+ });
80
+ });
@@ -4,6 +4,8 @@ import type { ViewTransform } from '../instrument/types';
4
4
  export interface UsePanZoomOptions {
5
5
  view: ViewTransform;
6
6
  onViewChange: (v: ViewTransform) => void;
7
+ /** The opening `view.zoom` always stays reachable, widening these past
8
+ * whatever is passed here if it would otherwise exclude it. */
7
9
  minZoom?: number;
8
10
  maxZoom?: number;
9
11
  }
@@ -26,6 +28,10 @@ interface DragState {
26
28
 
27
29
  const DRAG_THRESHOLD = 3;
28
30
 
31
+ function isPositiveFinite(n: number): boolean {
32
+ return Number.isFinite(n) && n > 0;
33
+ }
34
+
29
35
  export function usePanZoom({
30
36
  view,
31
37
  onViewChange,
@@ -35,6 +41,10 @@ export function usePanZoom({
35
41
  const dragRef = useRef<DragState | null>(null);
36
42
  const viewRef = useRef(view);
37
43
  viewRef.current = view;
44
+ // Captured once, from the view the canvas opened at — not the current view,
45
+ // which would let a prior zoom-out shrink the range and trap the opening
46
+ // view unreachable behind it.
47
+ const initialZoomRef = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
38
48
 
39
49
  const onWheel = useCallback(
40
50
  (e: WheelEvent<HTMLElement>) => {
@@ -43,9 +53,13 @@ export function usePanZoom({
43
53
  const cursorX = e.clientX - rect.left;
44
54
  const cursorY = e.clientY - rect.top;
45
55
  const v = viewRef.current;
56
+ const initialZoom = initialZoomRef.current;
57
+ const effectiveMin = initialZoom == null ? minZoom : Math.min(minZoom, initialZoom);
58
+ const effectiveMax = initialZoom == null ? maxZoom : Math.max(maxZoom, initialZoom);
46
59
  const factor = Math.exp(-e.deltaY * 0.001);
47
- const nextZoom = Math.min(maxZoom, Math.max(minZoom, v.zoom * factor));
48
- const ratio = nextZoom / v.zoom;
60
+ const rawZoom = isPositiveFinite(v.zoom) ? v.zoom : 1;
61
+ const nextZoom = Math.min(effectiveMax, Math.max(effectiveMin, rawZoom * factor));
62
+ const ratio = nextZoom / rawZoom;
49
63
  const nextPan = {
50
64
  x: cursorX - (cursorX - v.pan.x) * ratio,
51
65
  y: cursorY - (cursorY - v.pan.y) * ratio,
@@ -0,0 +1,106 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { CrosshairIcon, HandIcon, PencilIcon } from '@weasel-js/ui';
3
+ import type { ConfigField } from '../controls/types';
4
+ import type { Instrument } from '../instrument/types';
5
+ import { Lab } from '../lab/Lab';
6
+
7
+ interface DemoConfig extends Record<string, unknown> {
8
+ radius: number;
9
+ filled: boolean;
10
+ }
11
+
12
+ interface DemoState extends Record<string, unknown> {
13
+ spokes: number;
14
+ }
15
+
16
+ const fields: ConfigField[] = [
17
+ { key: 'radius', label: 'Radius', type: 'slider', default: 60, min: 10, max: 120, step: 1 },
18
+ { key: 'filled', label: 'Filled', type: 'checkbox', default: true },
19
+ ];
20
+
21
+ /** Declares every capability that contributes chrome, so all five trial
22
+ * regions render at once. */
23
+ const FullInstrument: Instrument<DemoState, DemoConfig> = {
24
+ name: 'Every Region',
25
+ defaultConfig: () => ({ radius: 60, filled: true }),
26
+ initialState: () => ({ spokes: 7 }),
27
+ configSchema: () => fields,
28
+ undo: {},
29
+ layers: { ids: ['wheel'] },
30
+ tools: {
31
+ tools: [
32
+ { id: 'draw', label: 'Draw', icon: PencilIcon, shortcut: 'D' },
33
+ { id: 'pan', label: 'Pan', icon: HandIcon, shortcut: 'H' },
34
+ { id: 'measure', label: 'Measure', icon: CrosshairIcon, shortcut: 'M' },
35
+ ],
36
+ },
37
+ canvas: {
38
+ layers: [
39
+ {
40
+ id: 'wheel',
41
+ draw: (ctx, { state, config }) => {
42
+ ctx.strokeStyle = '#8ab';
43
+ ctx.fillStyle = 'rgba(136, 170, 187, 0.25)';
44
+ ctx.lineWidth = 1.5;
45
+ ctx.beginPath();
46
+ ctx.arc(160, 120, config.radius, 0, Math.PI * 2);
47
+ if (config.filled) ctx.fill();
48
+ ctx.stroke();
49
+ for (let i = 0; i < state.spokes; i++) {
50
+ const a = (i / state.spokes) * Math.PI * 2;
51
+ ctx.beginPath();
52
+ ctx.moveTo(160, 120);
53
+ ctx.lineTo(160 + Math.cos(a) * config.radius, 120 + Math.sin(a) * config.radius);
54
+ ctx.stroke();
55
+ }
56
+ },
57
+ },
58
+ ],
59
+ },
60
+ render: ({ trial }) => (
61
+ <div className="lk-region-demo-readout">tool: {trial.activeToolId ?? 'none'}</div>
62
+ ),
63
+ };
64
+
65
+ const meta: Meta<typeof Lab> = {
66
+ title: 'labkit/Chrome/Regions',
67
+ component: Lab,
68
+ parameters: { layout: 'fullscreen' },
69
+ };
70
+ export default meta;
71
+
72
+ type Story = StoryObj<typeof Lab>;
73
+
74
+ export const EveryRegion: Story = {
75
+ args: {
76
+ instruments: [FullInstrument],
77
+ defaultInstrument: 'Every Region',
78
+ title: 'Chrome Regions',
79
+ storage: null,
80
+ },
81
+ };
82
+
83
+ /** A consumer dropping a built-in and adding its own in its place. */
84
+ export const SuppressedAndReplaced: Story = {
85
+ args: {
86
+ instruments: [FullInstrument],
87
+ defaultInstrument: 'Every Region',
88
+ title: 'Suppress + Replace',
89
+ storage: null,
90
+ suppress: ['snapshot'],
91
+ chrome: [
92
+ {
93
+ id: 'export',
94
+ region: 'toolbar',
95
+ group: 'trial',
96
+ end: true,
97
+ item: {
98
+ icon: CrosshairIcon,
99
+ label: 'Export',
100
+ showLabel: true,
101
+ onActivate: () => {},
102
+ },
103
+ },
104
+ ],
105
+ },
106
+ };
@@ -0,0 +1,153 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { f } from '../config/builder';
3
+ import { fromConfigFields } from '../config/fromConfigField';
4
+ import { resolveConfigSchema } from '../config/resolve';
5
+ import type { Instrument } from '../instrument/types';
6
+ import { builtinContributions } from './builtins';
7
+ import type { TrialChromeContext } from './types';
8
+
9
+ const ctx: TrialChromeContext = {
10
+ trialId: 't1',
11
+ instrumentName: 'Stub',
12
+ isLastTrial: false,
13
+ zoom: 1,
14
+ setZoom: () => {},
15
+ canUndo: true,
16
+ canRedo: false,
17
+ undo: () => {},
18
+ redo: () => {},
19
+ configFields: [],
20
+ configSchema: fromConfigFields([]),
21
+ config: {},
22
+ setConfig: () => {},
23
+ savedSnapshots: [],
24
+ saveSnapshot: () => {},
25
+ loadSnapshot: () => {},
26
+ clone: () => {},
27
+ reset: () => {},
28
+ close: () => {},
29
+ activeToolId: null,
30
+ setActiveTool: () => {},
31
+ };
32
+
33
+ const bare: Instrument = {
34
+ name: 'Stub',
35
+ defaultConfig: () => ({}),
36
+ initialState: () => ({}),
37
+ render: () => null,
38
+ };
39
+
40
+ const withCanvas: Instrument = { ...bare, canvas: { layers: [] } };
41
+
42
+ const ctxWithZoom = (zoom: number | null): TrialChromeContext => ({ ...ctx, zoom });
43
+
44
+ function ids(instrument: Instrument, c: TrialChromeContext = ctx): string[] {
45
+ return builtinContributions(instrument, c).map((x) => x.id);
46
+ }
47
+
48
+ describe('builtinContributions', () => {
49
+ it('always contributes the trial actions', () => {
50
+ expect(ids(bare)).toEqual(['snapshot', 'clone', 'reset', 'close']);
51
+ });
52
+
53
+ it('contributes undo and redo only when the instrument declares undo', () => {
54
+ expect(ids(bare)).not.toContain('undo');
55
+ expect(ids({ ...bare, undo: {} })).toContain('undo');
56
+ expect(ids({ ...bare, undo: {} })).toContain('redo');
57
+ });
58
+
59
+ it('puts zoom controls in the viewport region, not the toolbar', () => {
60
+ const zoomIn = builtinContributions(withCanvas, ctx).find((c) => c.id === 'zoom-in');
61
+ expect(zoomIn?.region).toBe('viewport');
62
+ });
63
+
64
+ it('contributes no viewport controls when the view is not 2D', () => {
65
+ expect(ids(withCanvas, ctxWithZoom(null))).not.toContain('zoom-in');
66
+ });
67
+
68
+ it('contributes a settings section only when the schema has fields', () => {
69
+ expect(ids(bare)).not.toContain('settings');
70
+ const withFields = {
71
+ ...ctx,
72
+ configSchema: fromConfigFields([
73
+ { key: 'n', label: 'N', type: 'number' as const, default: 1 },
74
+ ]),
75
+ };
76
+ expect(ids(bare, withFields)).toContain('settings');
77
+ });
78
+
79
+ it('contributes a settings section for a builder schema too', () => {
80
+ const withSchema = {
81
+ ...ctx,
82
+ configSchema: resolveConfigSchema(f.schema({ showGrid: f.boolean(true) }), []),
83
+ };
84
+ expect(ids(bare, withSchema)).toContain('settings');
85
+ });
86
+
87
+ it('reflects undo availability in the item, not by omitting it', () => {
88
+ const withUndo: Instrument = { ...bare, undo: {} };
89
+ const list = builtinContributions(withUndo, { ...ctx, canUndo: false });
90
+ const undo = list.find((c) => c.id === 'undo');
91
+ expect(undo?.item).toMatchObject({ disabled: true });
92
+ });
93
+
94
+ it('marks close as danger and disables it on the last trial', () => {
95
+ const list = builtinContributions(bare, { ...ctx, isLastTrial: true });
96
+ expect(list.find((c) => c.id === 'close')?.item).toMatchObject({
97
+ danger: true,
98
+ disabled: true,
99
+ });
100
+ });
101
+
102
+ it('offers the snapshot loader only once a snapshot has been taken', () => {
103
+ expect(ids(bare)).not.toContain('snapshot-load');
104
+ const withSaves = {
105
+ ...ctx,
106
+ savedSnapshots: [
107
+ {
108
+ id: 's1',
109
+ name: 'First',
110
+ trialId: 't1',
111
+ instrumentName: 'Stub',
112
+ config: {},
113
+ state: {},
114
+ savedAt: 1,
115
+ },
116
+ ],
117
+ };
118
+ expect(ids(bare, withSaves)).toContain('snapshot-load');
119
+ });
120
+
121
+ it('produces no duplicate ids for a fully-declared instrument', () => {
122
+ const full: Instrument = {
123
+ ...bare,
124
+ undo: {},
125
+ canvas: { layers: [] },
126
+ layers: { ids: ['a'] },
127
+ };
128
+ const list = builtinContributions(full, ctx);
129
+ expect(new Set(list.map((c) => c.id)).size).toBe(list.length);
130
+ });
131
+ });
132
+
133
+ describe('view readouts', () => {
134
+ it('contributes fps and scale to the status bar when the trial has a canvas', () => {
135
+ const out = builtinContributions(withCanvas, ctxWithZoom(1));
136
+ const status = out.filter((c) => c.region === 'status').map((c) => c.id);
137
+ expect(status).toContain('fps');
138
+ expect(status).toContain('scale');
139
+ });
140
+
141
+ it('contributes the zoom control to the viewport, replacing the readout', () => {
142
+ const out = builtinContributions(withCanvas, ctxWithZoom(1));
143
+ const viewport = out.filter((c) => c.region === 'viewport').map((c) => c.id);
144
+ expect(viewport).toContain('zoom-control');
145
+ expect(out.map((c) => c.id)).not.toContain('zoom-readout');
146
+ });
147
+
148
+ it('contributes none of them to a trial with no canvas', () => {
149
+ const out = builtinContributions(bare, ctxWithZoom(null));
150
+ expect(out.map((c) => c.id)).not.toContain('fps');
151
+ expect(out.map((c) => c.id)).not.toContain('zoom-control');
152
+ });
153
+ });