@weasel-js/labkit 1.2.0 → 1.3.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 (227) 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/types-1Sdxy_Pv.d.ts +90 -0
  4. package/dist/_dts/types-BttTIent.d.ts +314 -0
  5. package/dist/_dts/types-DCGEcMO_.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 +3 -1
  9. package/dist/chrome/index.d.ts +86 -0
  10. package/dist/chrome/index.js +9 -0
  11. package/dist/chrome/index.js.map +1 -0
  12. package/dist/chunk-3XXPU73K.js +138 -0
  13. package/dist/chunk-3XXPU73K.js.map +1 -0
  14. package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
  15. package/dist/chunk-4TMMVIDM.js.map +1 -0
  16. package/dist/chunk-6X5RBC6G.js +366 -0
  17. package/dist/chunk-6X5RBC6G.js.map +1 -0
  18. package/dist/{chunk-574LJAV4.js → chunk-BDJXIBRZ.js} +33 -35
  19. package/dist/chunk-BDJXIBRZ.js.map +1 -0
  20. package/dist/{chunk-SFL7NFKN.js → chunk-BMW4TDP5.js} +15 -7
  21. package/dist/chunk-BMW4TDP5.js.map +1 -0
  22. package/dist/chunk-JDROYQM3.js +266 -0
  23. package/dist/chunk-JDROYQM3.js.map +1 -0
  24. package/dist/{chunk-THBG7FQZ.js → chunk-LGKRIUSW.js} +15 -12
  25. package/dist/chunk-LGKRIUSW.js.map +1 -0
  26. package/dist/{chunk-BOHF3PQO.js → chunk-QCICIHEO.js} +8129 -5196
  27. package/dist/chunk-QCICIHEO.js.map +1 -0
  28. package/dist/chunk-QRYPXSGP.js +212 -0
  29. package/dist/chunk-QRYPXSGP.js.map +1 -0
  30. package/dist/chunk-RGT4EJIB.js +6961 -0
  31. package/dist/chunk-RGT4EJIB.js.map +1 -0
  32. package/dist/chunk-SBXNQW4G.js +3 -0
  33. package/dist/chunk-SBXNQW4G.js.map +1 -0
  34. package/dist/chunk-T66HU3HX.js +17 -0
  35. package/dist/chunk-T66HU3HX.js.map +1 -0
  36. package/dist/chunk-WTI26YTM.js +266 -0
  37. package/dist/chunk-WTI26YTM.js.map +1 -0
  38. package/dist/controls/index.d.ts +19 -5
  39. package/dist/controls/index.js +3 -1
  40. package/dist/dragdrop/index.d.ts +46 -3
  41. package/dist/dragdrop/index.js +3 -1
  42. package/dist/index.d.ts +735 -565
  43. package/dist/index.js +661 -1019
  44. package/dist/index.js.map +1 -1
  45. package/dist/layers/index.d.ts +5 -3
  46. package/dist/layers/index.js +4 -1
  47. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  48. package/dist/passthrough/weasel-canvas.js +1 -1
  49. package/dist/passthrough/weasel-ui.d.ts +3811 -3671
  50. package/dist/passthrough/weasel-ui.js +3 -2
  51. package/dist/primitives/index.d.ts +102 -8
  52. package/dist/primitives/index.js +5 -1
  53. package/dist/state/index.d.ts +3 -2
  54. package/dist/state/index.js +2 -2
  55. package/dist/styles.css +490 -803
  56. package/dist/surface/index.js +2 -1
  57. package/dist/ui/layers/index.d.ts +8 -2
  58. package/dist/ui/layers/index.js +2 -3
  59. package/dist/undo/index.d.ts +4 -2
  60. package/package.json +16 -7
  61. package/src/canvas/AGENTS.md +4 -0
  62. package/src/canvas/CanvasStack.tsx +6 -2
  63. package/src/canvas/useLayerScheduler.ts +22 -15
  64. package/src/canvas/usePanZoom.test.ts +80 -0
  65. package/src/canvas/usePanZoom.ts +16 -2
  66. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  67. package/src/chrome/builtins.test.ts +153 -0
  68. package/src/chrome/builtins.tsx +205 -0
  69. package/src/chrome/index.ts +25 -0
  70. package/src/chrome/merge.test.ts +51 -0
  71. package/src/chrome/merge.ts +52 -0
  72. package/src/chrome/regions/PaletteRegion.less +14 -0
  73. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  74. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  75. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  76. package/src/chrome/regions/StatusRegion.tsx +31 -0
  77. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  78. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  79. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  80. package/src/chrome/regions/ViewportRegion.less +42 -0
  81. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  82. package/src/chrome/regions/regions.test.tsx +81 -0
  83. package/src/chrome/types.ts +127 -0
  84. package/src/config/builder.test.ts +93 -0
  85. package/src/config/builder.ts +220 -0
  86. package/src/config/fromConfigField.test.ts +93 -0
  87. package/src/config/fromConfigField.ts +74 -0
  88. package/src/config/index.ts +22 -0
  89. package/src/config/resolve.test.ts +119 -0
  90. package/src/config/resolve.ts +67 -0
  91. package/src/config/rules.ts +68 -0
  92. package/src/config/types.ts +108 -0
  93. package/src/config/useConfigSchema.ts +30 -0
  94. package/src/config/visible.ts +20 -0
  95. package/src/controls/ControlPanel.less +4 -58
  96. package/src/controls/ControlPanel.stories.tsx +125 -0
  97. package/src/controls/ControlPanel.test.tsx +245 -10
  98. package/src/controls/ControlPanel.tsx +236 -203
  99. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  100. package/src/dragdrop/DragGhost.less +1 -1
  101. package/src/dragdrop/Palette.less +1 -1
  102. package/src/dragdrop/index.ts +11 -0
  103. package/src/index.ts +74 -5
  104. package/src/instrument/SineWave.smoke.test.tsx +8 -1
  105. package/src/instrument/defineInstrument.test.ts +38 -1
  106. package/src/instrument/defineInstrument.ts +31 -4
  107. package/src/instrument/index.ts +1 -0
  108. package/src/instrument/types.ts +27 -4
  109. package/src/instrument/validateConfigSchema.test.ts +13 -3
  110. package/src/instrument/validateConfigSchema.ts +7 -10
  111. package/src/lab/Lab.chrome.test.tsx +65 -0
  112. package/src/lab/Lab.tsx +58 -15
  113. package/src/lab/LabContext.ts +6 -0
  114. package/src/lab/LabFullChrome.stories.tsx +308 -0
  115. package/src/lab/LabHeader.test.tsx +42 -0
  116. package/src/lab/LabHeader.tsx +57 -0
  117. package/src/lab/LabPalette.test.tsx +35 -0
  118. package/src/lab/LabPalette.tsx +34 -0
  119. package/src/lab/LabShell.less +50 -3
  120. package/src/lab/Workspace.less +1 -30
  121. package/src/lab/Workspace.test.tsx +20 -10
  122. package/src/lab/Workspace.tsx +5 -8
  123. package/src/lab/index.ts +3 -1
  124. package/src/layers/AGENTS.md +1 -1
  125. package/src/layers/LayerList.less +31 -7
  126. package/src/layers/LayerList.tsx +17 -4
  127. package/src/passthrough/weasel-ui.ts +28 -0
  128. package/src/primitives/FloatingPanel.less +28 -0
  129. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  130. package/src/primitives/FloatingPanel.test.tsx +240 -0
  131. package/src/primitives/FloatingPanel.tsx +190 -0
  132. package/src/primitives/FpsMeter.test.tsx +44 -0
  133. package/src/primitives/FpsMeter.tsx +20 -9
  134. package/src/primitives/JobProgress.less +69 -0
  135. package/src/primitives/JobProgress.stories.tsx +48 -0
  136. package/src/primitives/JobProgress.tsx +65 -0
  137. package/src/primitives/Legend.less +47 -0
  138. package/src/primitives/Legend.stories.tsx +32 -0
  139. package/src/primitives/Legend.test.tsx +57 -0
  140. package/src/primitives/Legend.tsx +40 -0
  141. package/src/primitives/Sidebar.less +32 -2
  142. package/src/primitives/StatusBar.less +7 -0
  143. package/src/primitives/StatusBar.tsx +10 -3
  144. package/src/primitives/Toolbar.less +50 -3
  145. package/src/primitives/Toolbar.test.tsx +67 -1
  146. package/src/primitives/Toolbar.tsx +72 -6
  147. package/src/primitives/ZoomControl.less +31 -0
  148. package/src/primitives/ZoomControl.tsx +76 -0
  149. package/src/primitives/floating.entry.test.tsx +14 -0
  150. package/src/primitives/index.ts +10 -2
  151. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  152. package/src/primitives/useRovingTabIndex.ts +79 -0
  153. package/src/state/store.ts +11 -3
  154. package/src/state/toolSlot.test.ts +36 -0
  155. package/src/state/types.ts +4 -0
  156. package/src/styles.less +6 -4
  157. package/src/surface/useTiledSurface.ts +14 -10
  158. package/src/test-setup.ts +26 -0
  159. package/src/theme/Interstellar.stories.tsx +1 -1
  160. package/src/theme/base.less +19 -2
  161. package/src/theme/interstellar.tokens.json +6 -2
  162. package/src/tools/types.ts +24 -0
  163. package/src/trial/Trial.config.test.tsx +108 -0
  164. package/src/trial/Trial.job.test.tsx +13 -15
  165. package/src/trial/Trial.less +115 -10
  166. package/src/trial/Trial.stories.tsx +1 -0
  167. package/src/trial/Trial.test.tsx +67 -96
  168. package/src/trial/Trial.tsx +80 -39
  169. package/src/trial/TrialChrome.tsx +99 -80
  170. package/src/trial/TrialDragContext.ts +10 -0
  171. package/src/trial/TrialTitleBar.tsx +41 -0
  172. package/src/trial/index.ts +1 -15
  173. package/src/trial/trialChrome.borders.test.ts +38 -0
  174. package/src/ui/layers/index.ts +5 -1
  175. package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
  176. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  177. package/dist/chunk-574LJAV4.js.map +0 -1
  178. package/dist/chunk-5R2ATYPJ.js +0 -201
  179. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  180. package/dist/chunk-73KA7WBO.js +0 -5992
  181. package/dist/chunk-73KA7WBO.js.map +0 -1
  182. package/dist/chunk-BOHF3PQO.js.map +0 -1
  183. package/dist/chunk-G5TJVQQT.js +0 -161
  184. package/dist/chunk-G5TJVQQT.js.map +0 -1
  185. package/dist/chunk-PMAU3SEE.js +0 -126
  186. package/dist/chunk-PMAU3SEE.js.map +0 -1
  187. package/dist/chunk-SFL7NFKN.js.map +0 -1
  188. package/dist/chunk-THBG7FQZ.js.map +0 -1
  189. package/dist/chunk-VUU5UXHE.js.map +0 -1
  190. package/src/instrument/capabilityDetector.test.ts +0 -64
  191. package/src/instrument/capabilityDetector.ts +0 -20
  192. package/src/trial/DefaultSidebar.tsx +0 -29
  193. package/src/trial/DefaultStatusBar.tsx +0 -19
  194. package/src/trial/DefaultToolbar.tsx +0 -92
  195. package/src/trial/slotTypes.ts +0 -54
  196. package/src/ui/format.test.ts +0 -33
  197. package/src/ui/format.ts +0 -30
  198. package/src/ui/layers/LayerStack.less +0 -143
  199. package/src/ui/layers/LayerStack.stories.tsx +0 -45
  200. package/src/ui/layers/LayerStack.test.tsx +0 -170
  201. package/src/ui/layers/LayerStack.tsx +0 -220
  202. package/src/ui/properties/CheckboxRow.stories.tsx +0 -27
  203. package/src/ui/properties/ColorRow.stories.tsx +0 -106
  204. package/src/ui/properties/CurveField.less +0 -48
  205. package/src/ui/properties/CurveField.stories.tsx +0 -20
  206. package/src/ui/properties/CurveField.test.tsx +0 -127
  207. package/src/ui/properties/CurveField.tsx +0 -184
  208. package/src/ui/properties/EffectCard.tsx +0 -324
  209. package/src/ui/properties/Gallery.stories.tsx +0 -178
  210. package/src/ui/properties/NumberRow.stories.tsx +0 -65
  211. package/src/ui/properties/PropertyGroup.less +0 -50
  212. package/src/ui/properties/PropertyGroup.stories.tsx +0 -34
  213. package/src/ui/properties/PropertyGroup.test.tsx +0 -33
  214. package/src/ui/properties/PropertyGroup.tsx +0 -45
  215. package/src/ui/properties/PropertyList.stories.tsx +0 -83
  216. package/src/ui/properties/PropertyPanel.less +0 -592
  217. package/src/ui/properties/PropertyPanel.stories.tsx +0 -190
  218. package/src/ui/properties/PropertyPanel.test.tsx +0 -193
  219. package/src/ui/properties/PropertyPanel.tsx +0 -469
  220. package/src/ui/properties/PropertyRow.stories.tsx +0 -53
  221. package/src/ui/properties/SelectRow.stories.tsx +0 -58
  222. package/src/ui/properties/SliderRow.stories.tsx +0 -74
  223. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +0 -396
  224. package/src/ui/properties/TextRow.stories.tsx +0 -62
  225. package/src/ui/properties/ToggleRow.stories.tsx +0 -57
  226. package/src/ui/properties/index.ts +0 -39
  227. package/src/ui/properties/storyLayouts.tsx +0 -36
@@ -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-LGKRIUSW.js';
2
+ import '../chunk-QCICIHEO.js';
2
3
  //# sourceMappingURL=index.js.map
3
4
  //# sourceMappingURL=index.js.map
@@ -14,7 +14,9 @@ interface LayerStackItem {
14
14
  * can switch mode/shape without expanding. */
15
15
  primaryValue?: string;
16
16
  primaryOptions?: string[];
17
- /** Accent CSS color used as the left border / index-badge fill. */
17
+ /** Accent CSS color used as the left border / index-badge fill. Sets
18
+ * --wzl-layer-stack-accent on the card, which re-binds --wzl-accent
19
+ * for everything inside it. */
18
20
  accent?: string;
19
21
  /** Optional badge text rendered before the primary control
20
22
  * (e.g. tail index "1", "2", "3"). When omitted a drag handle
@@ -38,10 +40,14 @@ interface LayerStackProps {
38
40
  /** Hide the title + palette row (used when an outer wrap renders its
39
41
  * own head — see speech-balloons Tails panel). */
40
42
  hideHead?: boolean;
43
+ /** Appended to the root element's class list. The module's own class names
44
+ * are hashed, so this is the supported way to reach the stack from a
45
+ * consumer stylesheet. */
46
+ className?: string;
41
47
  }
42
48
  /** A drag-reorderable stack of expandable cards, with a palette in the header
43
49
  * for adding more. The body of each card is the caller's to render. */
44
- declare function LayerStack({ title, items, paletteKinds, onAdd, onRemove, onReorder, onPrimaryChange, renderBody, hideHead, }: LayerStackProps): react_jsx_runtime.JSX.Element;
50
+ declare function LayerStack({ className, title, items, paletteKinds, onAdd, onRemove, onReorder, onPrimaryChange, renderBody, hideHead, }: LayerStackProps): react_jsx_runtime.JSX.Element;
45
51
 
46
52
  export { LayerStack };
47
53
  export type { LayerStackItem, LayerStackProps };
@@ -1,5 +1,4 @@
1
- export { LayerStack } from '../../chunk-G5TJVQQT.js';
2
- import '../../chunk-73KA7WBO.js';
3
- import '../../chunk-BOHF3PQO.js';
1
+ export { c3 as LayerStack } from '../../chunk-RGT4EJIB.js';
2
+ import '../../chunk-QCICIHEO.js';
4
3
  //# sourceMappingURL=index.js.map
5
4
  //# 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/types-BttTIent.js';
2
2
  import 'react';
3
- import '../_dts/types-x92Kfeme.js';
3
+ import '../_dts/types-DCGEcMO_.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",
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",
113
+ "@weasel-js/theme": "1.3.0",
114
+ "@weasel-js/ui": "1.3.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",
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
+ });