@weasel-js/labkit 1.5.0 → 1.5.2

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 (206) hide show
  1. package/README.md +41 -4
  2. package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CMMo180l.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-ZTiwzYyT.d.ts +479 -0
  5. package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4hFJMiN.d.ts} +54 -6
  6. package/dist/_dts/{index-retnAut7.d.ts → index-8LiKe3A-.d.ts} +77 -10
  7. package/dist/_dts/{index-B8uu2ba7.d.ts → index-DlKolEIZ.d.ts} +20 -8
  8. package/dist/_dts/{types-Ca2LCnPq.d.ts → types-Db-3NNk1.d.ts} +28 -4
  9. package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-SSTZe410.d.ts} +4 -47
  10. package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-pnAE-UBD.d.ts} +1 -1
  11. package/dist/canvas/index.d.ts +5 -5
  12. package/dist/canvas/index.js +1 -1
  13. package/dist/chrome/index.d.ts +4 -4
  14. package/dist/chrome/index.js +7 -7
  15. package/dist/chunk-6BUQ77ZV.js +18 -0
  16. package/dist/chunk-6BUQ77ZV.js.map +1 -0
  17. package/dist/{chunk-KJALCDWE.js → chunk-753Y6HE6.js} +9 -9
  18. package/dist/chunk-753Y6HE6.js.map +1 -0
  19. package/dist/{chunk-6ZDGOZQV.js → chunk-A2ACAGW6.js} +72 -20
  20. package/dist/chunk-A2ACAGW6.js.map +1 -0
  21. package/dist/{chunk-NS64DXMN.js → chunk-CKUKF72R.js} +44 -21
  22. package/dist/chunk-CKUKF72R.js.map +1 -0
  23. package/dist/{chunk-MUKOW3TC.js → chunk-D35PFTS7.js} +4 -4
  24. package/dist/{chunk-MUKOW3TC.js.map → chunk-D35PFTS7.js.map} +1 -1
  25. package/dist/{chunk-UU3NJO6N.js → chunk-EMXAIRUJ.js} +3 -3
  26. package/dist/{chunk-UU3NJO6N.js.map → chunk-EMXAIRUJ.js.map} +1 -1
  27. package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
  28. package/dist/chunk-ERFMC6WU.js.map +1 -0
  29. package/dist/chunk-FKLT3ITC.js +590 -0
  30. package/dist/chunk-FKLT3ITC.js.map +1 -0
  31. package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
  32. package/dist/chunk-IJJUK4FU.js.map +1 -0
  33. package/dist/{chunk-EKIICY6X.js → chunk-KJCUERQF.js} +37 -25
  34. package/dist/chunk-KJCUERQF.js.map +1 -0
  35. package/dist/chunk-LASCBWW7.js +13456 -0
  36. package/dist/chunk-LASCBWW7.js.map +1 -0
  37. package/dist/{chunk-67SJLMC7.js → chunk-LRI3MBGR.js} +45 -12
  38. package/dist/chunk-LRI3MBGR.js.map +1 -0
  39. package/dist/chunk-REOBGYMC.js +150 -0
  40. package/dist/chunk-REOBGYMC.js.map +1 -0
  41. package/dist/{chunk-PGETSEDK.js → chunk-TT5C5S6R.js} +11 -90
  42. package/dist/chunk-TT5C5S6R.js.map +1 -0
  43. package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
  44. package/dist/chunk-Z76KEZAX.js.map +1 -0
  45. package/dist/config/index.d.ts +62 -19
  46. package/dist/config/index.js +6 -4
  47. package/dist/controls/index.d.ts +3 -3
  48. package/dist/controls/index.js +4 -4
  49. package/dist/dragdrop/index.d.ts +3 -3
  50. package/dist/index.d.ts +230 -62
  51. package/dist/index.js +1367 -1698
  52. package/dist/index.js.map +1 -1
  53. package/dist/layers/index.d.ts +4 -4
  54. package/dist/layers/index.js +2 -2
  55. package/dist/loupe/index.d.ts +67 -16
  56. package/dist/loupe/index.js +1 -1
  57. package/dist/passthrough/weasel-ui.d.ts +57 -230
  58. package/dist/passthrough/weasel-ui.js +1 -1
  59. package/dist/primitives/index.d.ts +3 -1
  60. package/dist/primitives/index.js +3 -3
  61. package/dist/state/index.d.ts +5 -5
  62. package/dist/state/index.js +5 -3
  63. package/dist/state/index.js.map +1 -1
  64. package/dist/styles.css +266 -57
  65. package/dist/surface/index.js +1 -1
  66. package/dist/ui/layers/index.js +1 -1
  67. package/dist/undo/index.d.ts +3 -3
  68. package/package.json +9 -9
  69. package/src/annotations/AnnotationOverlay.tsx +3 -0
  70. package/src/annotations/Annotations.less +4 -0
  71. package/src/annotations/Annotations.overlay.test.tsx +20 -0
  72. package/src/annotations/svgNodes.ts +1 -0
  73. package/src/annotations/toolMap.test.ts +9 -5
  74. package/src/annotations/toolMap.ts +3 -0
  75. package/src/canvas/CanvasStack.stories.tsx +1 -1
  76. package/src/canvas/index.ts +0 -9
  77. package/src/canvas/usePanZoom.test.ts +28 -0
  78. package/src/canvas/usePanZoom.ts +11 -4
  79. package/src/chrome/ChromeRegions.stories.tsx +1 -1
  80. package/src/chrome/LabChrome.tsx +21 -5
  81. package/src/chrome/builtins.test.ts +1 -0
  82. package/src/chrome/builtins.tsx +1 -0
  83. package/src/chrome/index.ts +1 -0
  84. package/src/chrome/labTypes.ts +3 -3
  85. package/src/chrome/regions/PaletteRegion.test.tsx +18 -0
  86. package/src/chrome/regions/PaletteRegion.tsx +6 -10
  87. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  88. package/src/chrome/regions/TitleBarRegion.tsx +1 -1
  89. package/src/chrome/regions/ToolbarRegion.tsx +11 -7
  90. package/src/chrome/types.ts +11 -2
  91. package/src/config/auto.test.ts +12 -0
  92. package/src/config/auto.ts +15 -0
  93. package/src/config/autoConfig.test.ts +107 -0
  94. package/src/config/autoConfig.ts +153 -0
  95. package/src/config/builder.test.ts +38 -0
  96. package/src/config/builder.ts +55 -11
  97. package/src/config/index.ts +3 -0
  98. package/src/config/path.ts +7 -2
  99. package/src/config/resolve.test.ts +25 -0
  100. package/src/config/resolve.ts +17 -1
  101. package/src/config/types.ts +32 -2
  102. package/src/config/useResolvedConfig.ts +26 -0
  103. package/src/controls/AutoControls.stories.tsx +88 -0
  104. package/src/controls/ControlPanel.less +23 -0
  105. package/src/controls/ControlPanel.stories.tsx +83 -1
  106. package/src/controls/ControlPanel.test.tsx +457 -4
  107. package/src/controls/ControlPanel.tsx +534 -73
  108. package/src/index.test.ts +0 -2
  109. package/src/index.ts +33 -11
  110. package/src/instrument/serializers.ts +29 -1
  111. package/src/lab/Lab.chrome.test.tsx +16 -0
  112. package/src/lab/Lab.stories.tsx +1 -1
  113. package/src/lab/Lab.surface.test.tsx +7 -2
  114. package/src/lab/Lab.test.tsx +79 -1
  115. package/src/lab/Lab.tsx +124 -16
  116. package/src/lab/LabContext.ts +8 -0
  117. package/src/lab/LabFit.stories.tsx +4 -4
  118. package/src/lab/LabFullChrome.stories.tsx +1 -1
  119. package/src/lab/LabHeader.tsx +0 -1
  120. package/src/lab/LabRoot.test.tsx +56 -0
  121. package/src/lab/LabRoot.tsx +47 -0
  122. package/src/lab/LabShell.less +24 -3
  123. package/src/lab/LabShell.stories.tsx +50 -1
  124. package/src/lab/LabShell.tsx +18 -27
  125. package/src/lab/LabSwitcher.less +10 -10
  126. package/src/lab/LabSwitcher.test.tsx +14 -9
  127. package/src/lab/LabSwitcher.tsx +8 -4
  128. package/src/lab/Workspace.less +1 -1
  129. package/src/lab/Workspace.stories.tsx +1 -1
  130. package/src/lab/Workspace.surface.test.tsx +1 -1
  131. package/src/lab/index.ts +2 -0
  132. package/src/layers/LayerList.less +1 -1
  133. package/src/layers/LayerList.stories.tsx +2 -2
  134. package/src/loupe/AGENTS.md +16 -4
  135. package/src/loupe/LoupeGestures.tsx +67 -0
  136. package/src/loupe/TrialLoupe.tsx +43 -28
  137. package/src/loupe/index.ts +4 -0
  138. package/src/loupe/loupeActions.ts +98 -0
  139. package/src/loupe/useLoupe.test.tsx +59 -2
  140. package/src/loupe/useLoupe.ts +28 -48
  141. package/src/passthrough/weasel-ui.ts +3 -3
  142. package/src/primitives/FloatingPanel.stories.tsx +1 -1
  143. package/src/primitives/FpsMeter.less +6 -1
  144. package/src/primitives/FpsMeter.stories.tsx +1 -1
  145. package/src/primitives/FpsMeter.test.tsx +12 -4
  146. package/src/primitives/FpsMeter.tsx +6 -1
  147. package/src/primitives/JobProgress.stories.tsx +2 -2
  148. package/src/primitives/Legend.stories.tsx +1 -1
  149. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  150. package/src/primitives/Sidebar.less +7 -0
  151. package/src/primitives/Sidebar.stories.tsx +1 -1
  152. package/src/primitives/Split.test.tsx +25 -0
  153. package/src/primitives/Split.tsx +36 -20
  154. package/src/primitives/StatusBar.less +6 -0
  155. package/src/primitives/StatusBar.stories.tsx +1 -1
  156. package/src/primitives/Toolbar.stories.tsx +1 -1
  157. package/src/specimen/Specimen.less +95 -0
  158. package/src/specimen/Specimen.stories.tsx +13 -0
  159. package/src/specimen/Specimen.test.tsx +13 -0
  160. package/src/specimen/Specimen.tsx +888 -0
  161. package/src/state/Persistence.stories.tsx +4 -4
  162. package/src/state/store.test.ts +65 -0
  163. package/src/state/store.ts +32 -8
  164. package/src/state/types.ts +31 -2
  165. package/src/state/useTrialState.test.tsx +85 -0
  166. package/src/state/useTrialState.ts +9 -1
  167. package/src/state/view.test.ts +4 -4
  168. package/src/styles.less +1 -0
  169. package/src/surface/index.ts +1 -1
  170. package/src/surface/useSurfaceTile.test.tsx +1 -1
  171. package/src/surface/useTiledSurface.test.tsx +65 -10
  172. package/src/surface/useTiledSurface.ts +23 -11
  173. package/src/theme/Interstellar.stories.tsx +2 -2
  174. package/src/theme/interstellar.test.ts +16 -5
  175. package/src/theme/interstellar.theme.json +180 -0
  176. package/src/theme/interstellar.ts +8 -5
  177. package/src/tools/labTool.ts +3 -3
  178. package/src/trial/Trial.annotations.test.tsx +13 -4
  179. package/src/trial/Trial.canvas.test.tsx +36 -0
  180. package/src/trial/Trial.config.test.tsx +75 -2
  181. package/src/trial/Trial.less +4 -0
  182. package/src/trial/Trial.stories.tsx +4 -1
  183. package/src/trial/Trial.test.tsx +3 -0
  184. package/src/trial/Trial.tsx +21 -14
  185. package/src/trial/TrialChrome.tsx +17 -3
  186. package/src/trial/trialOps.test.ts +101 -1
  187. package/src/trial/trialOps.ts +62 -4
  188. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
  189. package/dist/chunk-67SJLMC7.js.map +0 -1
  190. package/dist/chunk-6ZDGOZQV.js.map +0 -1
  191. package/dist/chunk-E44SU6XS.js +0 -50
  192. package/dist/chunk-E44SU6XS.js.map +0 -1
  193. package/dist/chunk-EKIICY6X.js.map +0 -1
  194. package/dist/chunk-KJALCDWE.js.map +0 -1
  195. package/dist/chunk-NDRYLVOW.js.map +0 -1
  196. package/dist/chunk-NS64DXMN.js.map +0 -1
  197. package/dist/chunk-PGETSEDK.js.map +0 -1
  198. package/dist/chunk-QLSV2N3G.js.map +0 -1
  199. package/dist/chunk-S2SHZRD7.js +0 -7681
  200. package/dist/chunk-S2SHZRD7.js.map +0 -1
  201. package/dist/chunk-TQBBYTAF.js +0 -280
  202. package/dist/chunk-TQBBYTAF.js.map +0 -1
  203. package/dist/chunk-ZPH5WTWF.js.map +0 -1
  204. package/src/canvas/useOrbit.test.ts +0 -167
  205. package/src/canvas/useOrbit.ts +0 -156
  206. package/src/theme/interstellar.tokens.json +0 -61
@@ -1,4 +1,4 @@
1
- export { composeRects, rectsEqual, toDeviceRect, useTiledSurface } from '../chunk-QLSV2N3G.js';
1
+ export { composeRects, rectsEqual, toDeviceRect, useTiledSurface } from '../chunk-IJJUK4FU.js';
2
2
  export { SurfaceCanvasContext, SurfaceContext, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId } from '../chunk-WIG6XHJ7.js';
3
3
  import '../chunk-2YILQ7Y5.js';
4
4
  //# sourceMappingURL=index.js.map
@@ -1,3 +1,3 @@
1
- export { c3 as LayerStack } from '../../chunk-S2SHZRD7.js';
1
+ export { c4 as LayerStack } from '../../chunk-LASCBWW7.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
@@ -1,8 +1,8 @@
1
- export { as as UndoCapability } from '../_dts/frac-8R6UmlvN.js';
1
+ export { au as UndoCapability } from '../_dts/frac-B4hFJMiN.js';
2
2
  import 'react';
3
- import '../_dts/types-Ca2LCnPq.js';
3
+ import '../_dts/types-Db-3NNk1.js';
4
4
  import '@weasel-js/core';
5
- import '../_dts/PrefsForm.d-DDHFANkc.js';
5
+ import '../_dts/PrefsForm.d-DPy9I8It.js';
6
6
  import '../_dts/types-DJ79Tg5J.js';
7
7
 
8
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.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -115,17 +115,17 @@
115
115
  "dev:3d": "LABKIT_EXAMPLE=3d-lab vite"
116
116
  },
117
117
  "peerDependencies": {
118
- "@weasel-js/core": "1.5.0",
118
+ "@weasel-js/core": "1.5.2",
119
+ "@weasel-js/theme": "1.5.2",
119
120
  "react": "^19.0.0",
120
121
  "react-dom": "^19.0.0"
121
122
  },
122
123
  "dependencies": {
123
- "@weasel-js/geom": "1.5.0",
124
- "@weasel-js/kernel3d": "1.5.0",
125
- "@weasel-js/loupe": "1.5.0",
126
- "@weasel-js/svg": "1.5.0",
127
- "@weasel-js/theme": "1.5.0",
128
- "@weasel-js/ui": "1.5.0",
124
+ "@weasel-js/geom": "1.5.2",
125
+ "@weasel-js/kernel3d": "1.5.2",
126
+ "@weasel-js/loupe": "1.5.2",
127
+ "@weasel-js/svg": "1.5.2",
128
+ "@weasel-js/ui": "1.5.2",
129
129
  "earcut": "2.2.4",
130
130
  "polygon-clipping": "^0.15.7",
131
131
  "react-aria-components": "^1.5.0",
@@ -136,7 +136,7 @@
136
136
  "@biomejs/biome": "^2.4.12",
137
137
  "@rollup/plugin-alias": "^6.0.0",
138
138
  "@testing-library/jest-dom": "^6.6.0",
139
- "@weasel-js/modes": "1.5.0",
139
+ "@weasel-js/modes": "1.5.2",
140
140
  "less": "^4.2.0",
141
141
  "rollup-plugin-dts": "^6.4.1",
142
142
  "tsx": "^4.19.0",
@@ -226,6 +226,9 @@ export function AnnotationOverlay({
226
226
  ref={setInput}
227
227
  className="lk-annotate__input"
228
228
  data-annotation-target={id}
229
+ // Pass-through while the rail holds a tool that makes no marks, so the
230
+ // instrument under the overlay stays clickable.
231
+ data-idle={annotationToolInfo(activeToolId) ? undefined : ''}
229
232
  role="application"
230
233
  aria-label={`Annotations on ${id}`}
231
234
  // biome-ignore lint/a11y/noNoninteractiveTabindex: role="application" IS the interactive case — a region with its own keymap. The rule's allowed-role list stops at the native widget roles and does not include it, and the gesture dispatcher listens on this element, so without focus no tool's keyboard binding ever fires.
@@ -8,6 +8,10 @@
8
8
  width: var(--lk-anno-w);
9
9
  height: var(--lk-anno-h);
10
10
  z-index: 2;
11
+
12
+ &[data-idle] {
13
+ pointer-events: none;
14
+ }
11
15
  }
12
16
 
13
17
  // The sidebar list of marks. One row per mark: what it is, where it is, what
@@ -146,6 +146,26 @@ describe('<AnnotationTargets>', () => {
146
146
  expect(boxes[1]?.style.getPropertyValue('--lk-anno-h')).toBe('100px');
147
147
  });
148
148
 
149
+ it('takes input while a mark tool is held, and passes it through otherwise', () => {
150
+ const drawing = render(<Harness toolId="rect" />, { wrapper: StrictMode });
151
+ act(() => {
152
+ vi.advanceTimersByTime(64);
153
+ });
154
+ expect(drawing.container.querySelector('.lk-annotate__input')?.hasAttribute('data-idle')).toBe(
155
+ false,
156
+ );
157
+ drawing.unmount();
158
+
159
+ // `pointer` is in the rail but not in the tool map: the instrument under the overlay stays clickable.
160
+ const idle = render(<Harness toolId="pointer" />, { wrapper: StrictMode });
161
+ act(() => {
162
+ vi.advanceTimersByTime(64);
163
+ });
164
+ expect(idle.container.querySelector('.lk-annotate__input')?.hasAttribute('data-idle')).toBe(
165
+ true,
166
+ );
167
+ });
168
+
149
169
  it('lands the boxes in the surface container, not among the instrument DOM', () => {
150
170
  const { container } = render(<Harness />, { wrapper: StrictMode });
151
171
  act(() => {
@@ -27,6 +27,7 @@ function toSvgStroke(stroke: Stroke): SvgStroke {
27
27
  ...(stroke.join ? { join: stroke.join } : {}),
28
28
  ...(stroke.dash ? { dash: [...stroke.dash] } : {}),
29
29
  ...(stroke.miterLimit != null ? { miterLimit: stroke.miterLimit } : {}),
30
+ ...(stroke.align ? { align: stroke.align } : {}),
30
31
  };
31
32
  }
32
33
 
@@ -6,8 +6,12 @@ import type { AnnotationKind } from './types';
6
6
  const KINDS: AnnotationKind[] = ['stroke', 'line', 'arrow', 'rect', 'ellipse', 'text'];
7
7
 
8
8
  describe('the annotation tool table', () => {
9
- it('offers one tool per mark kind, plus select', () => {
10
- expect(ANNOTATION_TOOLS.map((t) => t.id)).toEqual(['select', ...KINDS]);
9
+ it('offers one tool per mark kind, plus pointer and select', () => {
10
+ expect(ANNOTATION_TOOLS.map((t) => t.id)).toEqual(['pointer', 'select', ...KINDS]);
11
+ });
12
+
13
+ it('gives pointer no entry at all, which is what leaves the overlay idle', () => {
14
+ expect(annotationToolInfo('pointer')).toBeUndefined();
11
15
  });
12
16
 
13
17
  it('only names weasel tools SceneCanvas will mount', () => {
@@ -15,9 +19,9 @@ describe('the annotation tool table', () => {
15
19
  // being built in should fail here rather than in a silent no-op palette.
16
20
  const mountable = new Set<string>([...KIT_SHAPE_KINDS, 'select']);
17
21
  for (const t of ANNOTATION_TOOLS) {
18
- expect(mountable, `${t.id} maps to a tool the kit does not mount`).toContain(
19
- annotationToolInfo(t.id)?.weaselTool,
20
- );
22
+ const info = annotationToolInfo(t.id);
23
+ if (!info) continue;
24
+ expect(mountable, `${t.id} maps to a tool the kit does not mount`).toContain(info.weaselTool);
21
25
  }
22
26
  });
23
27
 
@@ -2,6 +2,7 @@ import type { BuiltinToolId } from '@weasel-js/core';
2
2
  import {
3
3
  ArrowIcon,
4
4
  EllipseIcon,
5
+ HandIcon,
5
6
  LineIcon,
6
7
  PencilIcon,
7
8
  RectIcon,
@@ -34,6 +35,8 @@ const TOOLS: Record<string, AnnotationToolInfo> = {
34
35
  * Ids share the lab's contribution namespace with its own `tools`, and the
35
36
  * lab's tool slot holds whichever is active. */
36
37
  export const ANNOTATION_TOOLS: readonly TrialTool[] = [
38
+ // Not in `TOOLS`: an id the map does not carry is what leaves the overlay idle.
39
+ { id: 'pointer', label: 'Interact', icon: HandIcon, group: 'annotate' },
37
40
  { id: 'select', label: 'Select', icon: SelectIcon, group: 'annotate' },
38
41
  { id: 'stroke', label: 'Freehand', icon: PencilIcon, group: 'annotate' },
39
42
  { id: 'line', label: 'Line', icon: LineIcon, group: 'annotate' },
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
2
  import { useState } from 'react';
3
3
  import { ScaleIndicator } from '../primitives/ScaleIndicator';
4
4
  import { CanvasStack } from './CanvasStack';
@@ -10,15 +10,6 @@ export { screenToWorld, worldToScreen } from './canvasCoords';
10
10
  export type { StageProps } from './Stage';
11
11
  export { fitStage, Stage } from './Stage';
12
12
  export type { CanvasLayerDescriptor } from './useLayerScheduler';
13
- export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './useOrbit';
14
- export {
15
- clampPitch,
16
- orbitAfterDrag,
17
- orbitAfterWheel,
18
- PITCH_LIMIT,
19
- useOrbit,
20
- wrapYaw,
21
- } from './useOrbit';
22
13
  export type { PanZoomHandlers, UsePanZoomOptions } from './usePanZoom';
23
14
  export { usePanZoom } from './usePanZoom';
24
15
  export type { ViewportSize, WorldFrame, WorldSpec } from './worldSpec';
@@ -37,6 +37,34 @@ function setup(initialView: ViewTransform, options?: Partial<UsePanZoomOptions>)
37
37
  }
38
38
 
39
39
  describe('usePanZoom', () => {
40
+ it('stacks two wheel events that land before a re-render', () => {
41
+ const { wheel } = setup({ zoom: 1, pan: { x: 0, y: 0 } });
42
+ const once = wheel(-100).zoom;
43
+
44
+ const views: ViewTransform[] = [];
45
+ const { result } = renderHook(() =>
46
+ usePanZoom({ view: { zoom: 1, pan: { x: 0, y: 0 } }, onViewChange: (v) => views.push(v) }),
47
+ );
48
+ act(() => {
49
+ result.current.onWheel(wheelEvent(-100));
50
+ result.current.onWheel(wheelEvent(-100));
51
+ });
52
+ expect(views[1].zoom).toBeCloseTo(once * once, 10);
53
+ });
54
+
55
+ it('lets the view prop win at the next render', () => {
56
+ const views: ViewTransform[] = [];
57
+ const { result, rerender } = renderHook(
58
+ (props: { view: ViewTransform }) =>
59
+ usePanZoom({ view: props.view, onViewChange: (v) => views.push(v) }),
60
+ { initialProps: { view: { zoom: 1, pan: { x: 0, y: 0 } } } },
61
+ );
62
+ act(() => result.current.onWheel(wheelEvent(-100)));
63
+ rerender({ view: { zoom: 2, pan: { x: 0, y: 0 } } });
64
+ act(() => result.current.onWheel(wheelEvent(0)));
65
+ expect(views[1].zoom).toBeCloseTo(2, 10);
66
+ });
67
+
40
68
  it('does not collapse a canvas opening far outside the default range on the first wheel event', () => {
41
69
  const { wheel } = setup({ zoom: 1600, pan: { x: 0, y: 0 } });
42
70
  const next = wheel(-1); // zoom in slightly
@@ -70,6 +70,13 @@ export function usePanZoom({
70
70
  // view unreachable behind it.
71
71
  const initialZoomRef = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
72
72
 
73
+ // Written ahead of the render so a second event inside one commit steps
74
+ // from this one; the render-time assignment above still lets the prop win.
75
+ const commit = useCallback((next: ViewTransform) => {
76
+ viewRef.current = normalize2DView(next);
77
+ onViewChangeRef.current(next);
78
+ }, []);
79
+
73
80
  const onWheel = useCallback(
74
81
  (e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => {
75
82
  const el = anchor ?? (e.currentTarget as Element | null);
@@ -77,7 +84,7 @@ export function usePanZoom({
77
84
  e.preventDefault();
78
85
  const rect = el.getBoundingClientRect();
79
86
  const initialZoom = initialZoomRef.current;
80
- onViewChange(
87
+ commit(
81
88
  zoomAt(
82
89
  viewRef.current,
83
90
  Math.exp(-e.deltaY * 0.001),
@@ -90,7 +97,7 @@ export function usePanZoom({
90
97
  ),
91
98
  );
92
99
  },
93
- [onViewChange, minZoom, maxZoom, frame],
100
+ [commit, minZoom, maxZoom, frame],
94
101
  );
95
102
 
96
103
  const clearDrag = useCallback(() => {
@@ -114,7 +121,7 @@ export function usePanZoom({
114
121
  const dy = ev.clientY - drag.startScreenY;
115
122
  if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
116
123
  drag.moved = true;
117
- onViewChangeRef.current({
124
+ commit({
118
125
  zoom: viewRef.current.zoom,
119
126
  pan: { x: drag.startPan.x + dx, y: drag.startPan.y + dy },
120
127
  });
@@ -129,7 +136,7 @@ export function usePanZoom({
129
136
  onCancel: clearDrag,
130
137
  });
131
138
  },
132
- [clearDrag],
139
+ [clearDrag, commit],
133
140
  );
134
141
 
135
142
  useEffect(() => () => sessionRef.current?.cancel(), []);
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
2
  import { CrosshairIcon, HandIcon, PencilIcon, SnapshotIcon } from '@weasel-js/ui';
3
3
  import type { ConfigField } from '../controls/types';
4
4
  import type { Instrument } from '../instrument/types';
@@ -80,12 +80,10 @@ export function LabHeaderRegion({ contributions }: LabRegionProps) {
80
80
  );
81
81
  }
82
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) {
83
+ function LabSections({ contributions, region }: LabRegionProps & { region: 'sidebar' | 'aside' }) {
86
84
  const lab = useLabChromeContext();
87
85
  const [collapsedSections, setFolds] = usePersistedState<Record<string, boolean>>(
88
- 'lk-lab-sidebar-folds',
86
+ `lk-lab-${region}-folds`,
89
87
  {},
90
88
  { scope: 'lab' },
91
89
  );
@@ -98,7 +96,25 @@ export function LabSidebarRegion({ contributions }: LabRegionProps) {
98
96
  }),
99
97
  [lab, collapsedSections, setFolds],
100
98
  );
101
- return <SidebarRegion contributions={contributionsIn(contributions, 'sidebar')} ctx={ctx} />;
99
+ return (
100
+ <SidebarRegion
101
+ contributions={contributionsIn(contributions, region)}
102
+ ctx={ctx}
103
+ region={region}
104
+ />
105
+ );
106
+ }
107
+
108
+ /** The lab's sidebar sections. Fold state is a lab-scoped persisted value, so it
109
+ * survives a reload in a stored lab and lasts the session in an unstored one. */
110
+ export function LabSidebarRegion({ contributions }: LabRegionProps) {
111
+ return <LabSections contributions={contributions} region="sidebar" />;
112
+ }
113
+
114
+ /** The lab's aside sections, on the far side of the workspace from the sidebar.
115
+ * Fold state persists the way the sidebar's does. */
116
+ export function LabAsideRegion({ contributions }: LabRegionProps) {
117
+ return <LabSections contributions={contributions} region="aside" />;
102
118
  }
103
119
 
104
120
  /** The lab's readouts, in the shell footer below the workspace. */
@@ -28,6 +28,7 @@ const ctx: TrialChromeContext = {
28
28
  configFields: [],
29
29
  configSchema: fromConfigFields([]),
30
30
  config: {},
31
+ auto: new Set<string>(),
31
32
  setConfig: () => {},
32
33
  savedSnapshots: [],
33
34
  saveSnapshot: () => {},
@@ -189,6 +189,7 @@ export function builtinContributions(
189
189
  <ControlPanel
190
190
  schema={ctx.configSchema}
191
191
  config={ctx.config as Record<string, unknown>}
192
+ auto={ctx.auto}
192
193
  setConfig={(key, value) => ctx.setConfig(String(key), value)}
193
194
  renderers={controls}
194
195
  collapsed={groupFolds(ctx.collapsedSections, 'settings')}
@@ -2,6 +2,7 @@ export { builtinContributions } from './builtins';
2
2
  export type { LabRegionProps } from './LabChrome';
3
3
  export {
4
4
  contributionsIn,
5
+ LabAsideRegion,
5
6
  LabFooterRegion,
6
7
  LabHeaderRegion,
7
8
  LabSidebarRegion,
@@ -15,7 +15,7 @@ import type {
15
15
  * `<LabShell>` names them; `palette` is the tool rail down the side of the
16
16
  * workspace; `sidebar` is a column of sections left of the tool rail.
17
17
  */
18
- export type LabRegion = 'header' | 'palette' | 'sidebar' | 'footer';
18
+ export type LabRegion = 'header' | 'palette' | 'sidebar' | 'aside' | 'footer';
19
19
 
20
20
  /**
21
21
  * Everything a lab-level contribution can read and command: the lab's own
@@ -40,8 +40,8 @@ export type LabContribution<TCtx = LabChromeContext> =
40
40
  item: ToolbarItem<TCtx>;
41
41
  render?: never;
42
42
  })
43
- | (ContributionBase & { region: 'palette'; item: ToolItem; render?: never })
44
- | (ContributionBase & { region: 'sidebar'; item: SidebarSection; render?: never })
43
+ | (ContributionBase & { region: 'palette'; item: ToolItem<TCtx>; render?: never })
44
+ | (ContributionBase & { region: 'sidebar' | 'aside'; item: SidebarSection; render?: never })
45
45
  | (ContributionBase & { region: 'footer'; item: StatusReadout; render?: never })
46
46
  | (ContributionBase & {
47
47
  region: LabRegion;
@@ -36,6 +36,24 @@ describe('PaletteRegion', () => {
36
36
  expect(setActiveTool).toHaveBeenCalledWith('brush');
37
37
  });
38
38
 
39
+ it('runs a command tool instead of selecting it, and never marks it current', () => {
40
+ const onActivate = vi.fn();
41
+ const setActiveTool = vi.fn();
42
+ const ctx = ctxWith('info', setActiveTool);
43
+ const info: RegionContribution<ToolSlotContext> = {
44
+ id: 'info',
45
+ region: 'palette',
46
+ item: { icon: Glyph, label: 'Info', onActivate },
47
+ };
48
+ render(<PaletteRegion contributions={[info]} ctx={ctx} />);
49
+ const button = screen.getByRole('button', { name: 'Info' });
50
+ button.click();
51
+ expect(onActivate).toHaveBeenCalledWith(ctx);
52
+ expect(setActiveTool).not.toHaveBeenCalled();
53
+ // The slot names this id, and a command still must not read as held down.
54
+ expect(button).not.toHaveAttribute('aria-current');
55
+ });
56
+
39
57
  it('puts exactly one tool in the tab order', () => {
40
58
  render(<PaletteRegion contributions={[tool('brush'), tool('eraser')]} ctx={ctxWith(null)} />);
41
59
  expect(screen.getByRole('button', { name: 'brush' })).toHaveAttribute('tabindex', '0');
@@ -1,11 +1,6 @@
1
1
  import { ToolButton, ToolGroup } from '../../passthrough/weasel-ui';
2
2
  import { useRovingTabIndex } from '../../primitives/useRovingTabIndex';
3
- import type {
4
- RegionContribution,
5
- ToolItem,
6
- ToolSlotContext,
7
- TrialChromeContext,
8
- } from '../types';
3
+ import type { RegionContribution, ToolItem, ToolSlotContext, TrialChromeContext } from '../types';
9
4
 
10
5
  /** Props for `<PaletteRegion>`. */
11
6
  export interface PaletteRegionProps<TCtx extends ToolSlotContext = TrialChromeContext> {
@@ -17,7 +12,8 @@ export interface PaletteRegionProps<TCtx extends ToolSlotContext = TrialChromeCo
17
12
  }
18
13
 
19
14
  /** A tool strip: a trial's, or the lab's. Selection lives in whichever tool
20
- * slot the context carries; this region only reflects it. */
15
+ * slot the context carries; this region only reflects it. An item carrying
16
+ * `onActivate` is a command instead — it presses, and never latches. */
21
17
  export function PaletteRegion<TCtx extends ToolSlotContext = TrialChromeContext>({
22
18
  contributions,
23
19
  ctx,
@@ -38,16 +34,16 @@ export function PaletteRegion<TCtx extends ToolSlotContext = TrialChromeContext>
38
34
  {contributions.map((c) => {
39
35
  if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
40
36
  if (c.region !== region || !c.item) return null;
41
- const { icon: Icon, label, shortcut, disabled } = c.item as ToolItem;
37
+ const { icon: Icon, label, shortcut, disabled, onActivate } = c.item as ToolItem<TCtx>;
42
38
  return (
43
39
  <ToolButton
44
40
  key={c.id}
45
41
  icon={<Icon size={16} />}
46
42
  label={label}
47
43
  shortcut={shortcut}
48
- active={ctx.activeToolId === c.id}
44
+ active={onActivate ? false : ctx.activeToolId === c.id}
49
45
  disabled={disabled}
50
- onClick={() => ctx.setActiveTool(c.id)}
46
+ onClick={onActivate ? () => onActivate(ctx) : () => ctx.setActiveTool(c.id)}
51
47
  />
52
48
  );
53
49
  })}
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
+ import { expect } from '@weasel-js/forge/play';
3
+ import { LabRoot } from '../../lab/LabRoot';
4
+ import type { SidebarSlotContext } from '../types';
5
+ import { SidebarRegion } from './SidebarRegion';
6
+
7
+ const meta: Meta<typeof SidebarRegion> = {
8
+ title: 'labkit/Chrome/SidebarRegion',
9
+ component: SidebarRegion,
10
+ parameters: { layout: 'fullscreen' },
11
+ };
12
+ export default meta;
13
+
14
+ type Story = StoryObj<typeof SidebarRegion>;
15
+
16
+ const ctx: SidebarSlotContext = { collapsedSections: {}, setSectionCollapsed: () => {} };
17
+
18
+ // jsdom has no layout, so this runs in the browser story projects.
19
+ export const LastSectionFillsItsPane: Story = {
20
+ render: () => (
21
+ <LabRoot>
22
+ <div className="lk-lab__aside" data-testid="pane" style={{ height: 400 }}>
23
+ <SidebarRegion
24
+ ctx={ctx}
25
+ contributions={[
26
+ { id: 'first', region: 'sidebar', item: { title: 'First', body: <p>one</p> } },
27
+ { id: 'last', region: 'sidebar', item: { title: 'Last', body: <p>two</p> } },
28
+ ]}
29
+ />
30
+ </div>
31
+ </LabRoot>
32
+ ),
33
+ play: async ({ canvasElement }) => {
34
+ const pane = canvasElement.querySelector('[data-testid="pane"]') as HTMLElement;
35
+ const sections = [...pane.querySelectorAll<HTMLElement>('.lk-sidebar-section')];
36
+ const first = sections[0] as HTMLElement;
37
+ const last = sections[sections.length - 1] as HTMLElement;
38
+ await expect(Math.round(last.getBoundingClientRect().bottom)).toBe(
39
+ Math.round(pane.getBoundingClientRect().bottom),
40
+ );
41
+ await expect(getComputedStyle(last).borderBottomWidth).toBe('0px');
42
+ await expect(getComputedStyle(first).borderBottomWidth).not.toBe('0px');
43
+ },
44
+ };
@@ -32,7 +32,7 @@ function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
32
32
  onPointerDown={(e) => e.stopPropagation()}
33
33
  onMouseDown={(e) => e.stopPropagation()}
34
34
  >
35
- <Icon size={14} />
35
+ <Icon size={16} />
36
36
  </button>
37
37
  );
38
38
  }
@@ -39,15 +39,19 @@ function groupsOf<TCtx>(contributions: readonly RegionContribution<TCtx>[]): Gro
39
39
  return groups;
40
40
  }
41
41
 
42
- function renderEntry<TCtx>(
43
- c: RegionContribution<TCtx>,
44
- ctx: TCtx,
45
- region: string,
46
- ): ReactNode {
42
+ function renderEntry<TCtx>(c: RegionContribution<TCtx>, ctx: TCtx, region: string): ReactNode {
47
43
  if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
48
44
  if (c.region !== region || !c.item) return null;
49
- const { icon: Icon, label, shortcut, disabled, danger, showLabel, pressed, onActivate } =
50
- c.item as ToolbarItem<TCtx>;
45
+ const {
46
+ icon: Icon,
47
+ label,
48
+ shortcut,
49
+ disabled,
50
+ danger,
51
+ showLabel,
52
+ pressed,
53
+ onActivate,
54
+ } = c.item as ToolbarItem<TCtx>;
51
55
  return (
52
56
  <Toolbar.Button
53
57
  key={c.id}
@@ -31,12 +31,18 @@ export interface ToolbarItem<TCtx = TrialChromeContext> {
31
31
  onActivate: (ctx: TCtx) => void;
32
32
  }
33
33
 
34
- /** A selectable tool in the palette region. */
35
- export interface ToolItem {
34
+ /** A tool in the palette region: a mode by default, a command when it carries
35
+ * an `onActivate`. `TCtx` is the chrome context that handler is handed — a
36
+ * trial's, or the lab's for a lab-level rail. */
37
+ export interface ToolItem<TCtx = TrialChromeContext> {
36
38
  icon: IconComponent;
37
39
  label: string;
40
+ /** Shown in the tooltip. Not bound here — the trial owns its keymap. */
38
41
  shortcut?: string;
39
42
  disabled?: boolean;
43
+ /** Runs on press instead of selecting. An item with one is a command: it
44
+ * never writes the tool slot, and never reads as the current tool. */
45
+ onActivate?: (ctx: TCtx) => void;
40
46
  }
41
47
 
42
48
  /** A titled block in the sidebar. */
@@ -165,6 +171,9 @@ export interface TrialChromeContext extends ToolSlotContext, SidebarSlotContext
165
171
  * `config`, since a builder schema has no `ConfigField[]` form. */
166
172
  configFields: ConfigField[];
167
173
  config: unknown;
174
+ /** The trial's unpinned paths. `config` stays raw, so a control drawn from
175
+ * it sits at the value un-pinning writes back. */
176
+ auto: ReadonlySet<string>;
168
177
  setConfig: (key: string, value: unknown) => void;
169
178
 
170
179
  /** Which of this trial's collapsible sections are folded. A sidebar
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { auto, isAuto } from './auto';
3
+
4
+ describe('auto', () => {
5
+ it('is a symbol distinct from every ordinary config value', () => {
6
+ expect(typeof auto).toBe('symbol');
7
+ expect(isAuto(auto)).toBe(true);
8
+ for (const v of [undefined, null, 0, '', 'auto', false, Symbol('auto'), {}]) {
9
+ expect(isAuto(v)).toBe(false);
10
+ }
11
+ });
12
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Written at a config path to mean "do not pin this — let the instrument
3
+ * decide". Accepted by `setConfig`, by a trial's `configSeed`, and by
4
+ * `.initial()` in a schema. It is normalized into the trial's set of unpinned
5
+ * paths on the way into the store and is never itself stored, so a serialized
6
+ * trial contains no sentinel and the last pinned value survives.
7
+ */
8
+ export const auto: unique symbol = Symbol('weasel.auto');
9
+
10
+ /** The type of the `auto` sentinel, for widening a value parameter. */
11
+ export type Auto = typeof auto;
12
+
13
+ export function isAuto(value: unknown): value is Auto {
14
+ return value === auto;
15
+ }