@weasel-js/labkit 1.5.0 → 1.5.1

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 (192) hide show
  1. package/README.md +41 -4
  2. package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CJNMS6mz.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-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4okC2lX.d.ts} +42 -4
  6. package/dist/_dts/{index-retnAut7.d.ts → index-BNdz-tNd.d.ts} +90 -10
  7. package/dist/_dts/{index-B8uu2ba7.d.ts → index-CSJoj4LC.d.ts} +17 -6
  8. package/dist/_dts/{types-Ca2LCnPq.d.ts → types-DzoNY1pO.d.ts} +28 -4
  9. package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-Pzf9f2jh.d.ts} +2 -2
  10. package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-uLlmaccn.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-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-PGETSEDK.js → chunk-CEIUBPPW.js} +10 -6
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-EKIICY6X.js → chunk-EHGVROVV.js} +37 -25
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-NS64DXMN.js → chunk-IHUVQ3TX.js} +39 -16
  26. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  27. package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
  28. package/dist/chunk-IJJUK4FU.js.map +1 -0
  29. package/dist/{chunk-67SJLMC7.js → chunk-IXTQI7JX.js} +45 -12
  30. package/dist/chunk-IXTQI7JX.js.map +1 -0
  31. package/dist/{chunk-MUKOW3TC.js → chunk-MDVTK5U3.js} +4 -4
  32. package/dist/{chunk-MUKOW3TC.js.map → chunk-MDVTK5U3.js.map} +1 -1
  33. package/dist/{chunk-UU3NJO6N.js → chunk-PHK4W562.js} +3 -3
  34. package/dist/{chunk-UU3NJO6N.js.map → chunk-PHK4W562.js.map} +1 -1
  35. package/dist/chunk-PJ7WEQBJ.js +18 -0
  36. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  37. package/dist/chunk-T3EC2DXD.js +12628 -0
  38. package/dist/chunk-T3EC2DXD.js.map +1 -0
  39. package/dist/{chunk-KJALCDWE.js → chunk-UN4U4P63.js} +4 -4
  40. package/dist/{chunk-KJALCDWE.js.map → chunk-UN4U4P63.js.map} +1 -1
  41. package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
  42. package/dist/chunk-Z76KEZAX.js.map +1 -0
  43. package/dist/{chunk-6ZDGOZQV.js → chunk-ZDHY47EV.js} +70 -17
  44. package/dist/chunk-ZDHY47EV.js.map +1 -0
  45. package/dist/config/index.d.ts +60 -16
  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 +177 -53
  51. package/dist/index.js +1333 -1683
  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 +26 -202
  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 +153 -40
  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/usePanZoom.test.ts +28 -0
  77. package/src/canvas/usePanZoom.ts +11 -4
  78. package/src/chrome/ChromeRegions.stories.tsx +1 -1
  79. package/src/chrome/LabChrome.tsx +21 -5
  80. package/src/chrome/builtins.test.ts +1 -0
  81. package/src/chrome/builtins.tsx +1 -0
  82. package/src/chrome/index.ts +1 -0
  83. package/src/chrome/labTypes.ts +2 -2
  84. package/src/chrome/regions/PaletteRegion.tsx +1 -6
  85. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +11 -7
  87. package/src/chrome/types.ts +3 -0
  88. package/src/config/auto.test.ts +12 -0
  89. package/src/config/auto.ts +15 -0
  90. package/src/config/autoConfig.test.ts +107 -0
  91. package/src/config/autoConfig.ts +153 -0
  92. package/src/config/builder.test.ts +38 -0
  93. package/src/config/builder.ts +53 -8
  94. package/src/config/index.ts +3 -0
  95. package/src/config/path.ts +7 -2
  96. package/src/config/resolve.test.ts +25 -0
  97. package/src/config/resolve.ts +17 -1
  98. package/src/config/types.ts +32 -2
  99. package/src/config/useResolvedConfig.ts +26 -0
  100. package/src/controls/AutoControls.stories.tsx +88 -0
  101. package/src/controls/ControlPanel.stories.tsx +42 -1
  102. package/src/controls/ControlPanel.test.tsx +514 -4
  103. package/src/controls/ControlPanel.tsx +557 -80
  104. package/src/index.ts +29 -1
  105. package/src/instrument/serializers.ts +29 -1
  106. package/src/lab/Lab.chrome.test.tsx +16 -0
  107. package/src/lab/Lab.stories.tsx +1 -1
  108. package/src/lab/Lab.surface.test.tsx +7 -2
  109. package/src/lab/Lab.test.tsx +79 -1
  110. package/src/lab/Lab.tsx +105 -13
  111. package/src/lab/LabContext.ts +8 -0
  112. package/src/lab/LabFit.stories.tsx +4 -4
  113. package/src/lab/LabFullChrome.stories.tsx +1 -1
  114. package/src/lab/LabHeader.tsx +0 -1
  115. package/src/lab/LabRoot.test.tsx +56 -0
  116. package/src/lab/LabRoot.tsx +47 -0
  117. package/src/lab/LabShell.less +24 -3
  118. package/src/lab/LabShell.stories.tsx +50 -1
  119. package/src/lab/LabShell.tsx +18 -27
  120. package/src/lab/LabSwitcher.less +10 -10
  121. package/src/lab/LabSwitcher.test.tsx +14 -9
  122. package/src/lab/LabSwitcher.tsx +8 -4
  123. package/src/lab/Workspace.less +1 -1
  124. package/src/lab/Workspace.stories.tsx +1 -1
  125. package/src/lab/Workspace.surface.test.tsx +1 -1
  126. package/src/lab/index.ts +2 -0
  127. package/src/layers/LayerList.stories.tsx +1 -1
  128. package/src/loupe/AGENTS.md +16 -4
  129. package/src/loupe/LoupeGestures.tsx +67 -0
  130. package/src/loupe/TrialLoupe.tsx +43 -28
  131. package/src/loupe/index.ts +4 -0
  132. package/src/loupe/loupeActions.ts +98 -0
  133. package/src/loupe/useLoupe.test.tsx +59 -2
  134. package/src/loupe/useLoupe.ts +28 -48
  135. package/src/passthrough/weasel-ui.ts +3 -3
  136. package/src/primitives/FloatingPanel.stories.tsx +1 -1
  137. package/src/primitives/FpsMeter.stories.tsx +1 -1
  138. package/src/primitives/JobProgress.stories.tsx +1 -1
  139. package/src/primitives/Legend.stories.tsx +1 -1
  140. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  141. package/src/primitives/Sidebar.less +7 -0
  142. package/src/primitives/Sidebar.stories.tsx +1 -1
  143. package/src/primitives/Split.test.tsx +25 -0
  144. package/src/primitives/Split.tsx +36 -20
  145. package/src/primitives/StatusBar.stories.tsx +1 -1
  146. package/src/primitives/Toolbar.stories.tsx +1 -1
  147. package/src/specimen/Specimen.less +95 -0
  148. package/src/specimen/Specimen.stories.tsx +13 -0
  149. package/src/specimen/Specimen.test.tsx +13 -0
  150. package/src/specimen/Specimen.tsx +888 -0
  151. package/src/state/Persistence.stories.tsx +4 -4
  152. package/src/state/store.test.ts +65 -0
  153. package/src/state/store.ts +32 -8
  154. package/src/state/types.ts +26 -2
  155. package/src/state/useTrialState.test.tsx +85 -0
  156. package/src/state/useTrialState.ts +9 -1
  157. package/src/styles.less +1 -0
  158. package/src/surface/index.ts +1 -1
  159. package/src/surface/useSurfaceTile.test.tsx +1 -1
  160. package/src/surface/useTiledSurface.test.tsx +65 -10
  161. package/src/surface/useTiledSurface.ts +23 -11
  162. package/src/theme/Interstellar.stories.tsx +2 -2
  163. package/src/theme/interstellar.test.ts +16 -5
  164. package/src/theme/interstellar.theme.json +180 -0
  165. package/src/theme/interstellar.ts +8 -5
  166. package/src/tools/labTool.ts +3 -3
  167. package/src/trial/Trial.annotations.test.tsx +13 -4
  168. package/src/trial/Trial.canvas.test.tsx +32 -0
  169. package/src/trial/Trial.config.test.tsx +75 -2
  170. package/src/trial/Trial.less +4 -0
  171. package/src/trial/Trial.stories.tsx +4 -1
  172. package/src/trial/Trial.test.tsx +3 -0
  173. package/src/trial/Trial.tsx +21 -14
  174. package/src/trial/TrialChrome.tsx +17 -3
  175. package/src/trial/trialOps.test.ts +101 -1
  176. package/src/trial/trialOps.ts +62 -4
  177. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
  178. package/dist/chunk-67SJLMC7.js.map +0 -1
  179. package/dist/chunk-6ZDGOZQV.js.map +0 -1
  180. package/dist/chunk-E44SU6XS.js +0 -50
  181. package/dist/chunk-E44SU6XS.js.map +0 -1
  182. package/dist/chunk-EKIICY6X.js.map +0 -1
  183. package/dist/chunk-NDRYLVOW.js.map +0 -1
  184. package/dist/chunk-NS64DXMN.js.map +0 -1
  185. package/dist/chunk-PGETSEDK.js.map +0 -1
  186. package/dist/chunk-QLSV2N3G.js.map +0 -1
  187. package/dist/chunk-S2SHZRD7.js +0 -7681
  188. package/dist/chunk-S2SHZRD7.js.map +0 -1
  189. package/dist/chunk-TQBBYTAF.js +0 -280
  190. package/dist/chunk-TQBBYTAF.js.map +0 -1
  191. package/dist/chunk-ZPH5WTWF.js.map +0 -1
  192. 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-T3EC2DXD.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 { at as UndoCapability } from '../_dts/frac-B4okC2lX.js';
2
2
  import 'react';
3
- import '../_dts/types-Ca2LCnPq.js';
3
+ import '../_dts/types-DzoNY1pO.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.1",
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.1",
119
+ "@weasel-js/theme": "1.5.1",
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.1",
125
+ "@weasel-js/kernel3d": "1.5.1",
126
+ "@weasel-js/loupe": "1.5.1",
127
+ "@weasel-js/svg": "1.5.1",
128
+ "@weasel-js/ui": "1.5.1",
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.1",
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';
@@ -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
@@ -41,7 +41,7 @@ export type LabContribution<TCtx = LabChromeContext> =
41
41
  render?: never;
42
42
  })
43
43
  | (ContributionBase & { region: 'palette'; item: ToolItem; render?: never })
44
- | (ContributionBase & { region: 'sidebar'; item: SidebarSection; 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;
@@ -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> {
@@ -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
+ };
@@ -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}
@@ -165,6 +165,9 @@ export interface TrialChromeContext extends ToolSlotContext, SidebarSlotContext
165
165
  * `config`, since a builder schema has no `ConfigField[]` form. */
166
166
  configFields: ConfigField[];
167
167
  config: unknown;
168
+ /** The trial's unpinned paths. `config` stays raw, so a control drawn from
169
+ * it sits at the value un-pinning writes back. */
170
+ auto: ReadonlySet<string>;
168
171
  setConfig: (key: string, value: unknown) => void;
169
172
 
170
173
  /** 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
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { auto } from './auto';
3
+ import { autoPathsOf, resolveAutoConfig } from './autoConfig';
4
+ import { f } from './builder';
5
+ import { resolveConfigSchema } from './resolve';
6
+
7
+ const schema = f.schema({
8
+ width: f.number(432),
9
+ gap: f.number(12).auto((c) => (c.width as number) / 24),
10
+ cols: f.number(3).initial(auto),
11
+ grid: f.group({ size: f.number(8).auto(() => 99) }),
12
+ });
13
+ const resolved = resolveConfigSchema(schema);
14
+ const raw = { width: 432, gap: 12, cols: 3, grid: { size: 8 } };
15
+
16
+ describe('autoPathsOf', () => {
17
+ it('lists the dotted paths a schema starts auto', () => {
18
+ expect(autoPathsOf(resolved)).toEqual(['cols']);
19
+ });
20
+
21
+ it('reaches leaves nested under a group', () => {
22
+ const nested = resolveConfigSchema(
23
+ f.schema({ grid: f.group({ size: f.number(8).initial(auto) }) }),
24
+ );
25
+ expect(autoPathsOf(nested)).toEqual(['grid.size']);
26
+ });
27
+ });
28
+
29
+ describe('resolveAutoConfig', () => {
30
+ it('leaves a config with nothing auto exactly as it was', () => {
31
+ expect(resolveAutoConfig(resolved, raw, new Set())).toEqual(raw);
32
+ });
33
+
34
+ it('replaces an auto path that has a resolver with the computed value', () => {
35
+ const out = resolveAutoConfig(resolved, raw, new Set(['gap']));
36
+ expect(out.gap).toBe(18);
37
+ expect(out.width).toBe(432);
38
+ });
39
+
40
+ it('deletes an auto path that has no resolver', () => {
41
+ const out = resolveAutoConfig(resolved, raw, new Set(['cols'])) as Record<string, unknown>;
42
+ expect('cols' in out).toBe(false);
43
+ });
44
+
45
+ it('resolves a leaf nested under a group', () => {
46
+ const out = resolveAutoConfig(resolved, raw, new Set(['grid.size'])) as {
47
+ grid: { size: number };
48
+ };
49
+ expect(out.grid.size).toBe(99);
50
+ });
51
+
52
+ it('lets one resolver read another auto path, already resolved', () => {
53
+ const chained = resolveConfigSchema(
54
+ f.schema({
55
+ a: f.number(1).auto(() => 10),
56
+ b: f.number(2).auto((c) => (c.a as number) * 3),
57
+ }),
58
+ );
59
+ const out = resolveAutoConfig(chained, { a: 1, b: 2 }, new Set(['a', 'b'])) as {
60
+ a: number;
61
+ b: number;
62
+ };
63
+ expect(out).toEqual({ a: 10, b: 30 });
64
+ });
65
+
66
+ it('resolves a dependency declared after its dependent', () => {
67
+ const backward = resolveConfigSchema(
68
+ f.schema({
69
+ b: f.number(2).auto((c) => (c.a as number) * 3),
70
+ a: f.number(1).auto(() => 10),
71
+ }),
72
+ );
73
+ const out = resolveAutoConfig(backward, { a: 1, b: 2 }, new Set(['a', 'b'])) as {
74
+ a: number;
75
+ b: number;
76
+ };
77
+ expect(out).toEqual({ a: 10, b: 30 });
78
+ });
79
+
80
+ it('throws naming the path when resolvers cycle', () => {
81
+ const cyclic = resolveConfigSchema(
82
+ f.schema({
83
+ a: f.number(1).auto((c) => (c.b as number) + 1),
84
+ b: f.number(2).auto((c) => (c.a as number) + 1),
85
+ }),
86
+ );
87
+ expect(() => resolveAutoConfig(cyclic, { a: 1, b: 2 }, new Set(['a', 'b']))).toThrow(/cycle/i);
88
+ expect(() => resolveAutoConfig(cyclic, { a: 1, b: 2 }, new Set(['a', 'b']))).toThrow(/"a"/);
89
+ });
90
+
91
+ it('throws on a resolver that reads its own path', () => {
92
+ const selfish = resolveConfigSchema(
93
+ f.schema({ a: f.number(1).auto((c) => (c.a as number) + 1) }),
94
+ );
95
+ expect(() => resolveAutoConfig(selfish, { a: 1 }, new Set(['a']))).toThrow(/cycle/i);
96
+ });
97
+
98
+ it('ignores an auto path the schema does not have', () => {
99
+ expect(resolveAutoConfig(resolved, raw, new Set(['gone']))).toEqual(raw);
100
+ });
101
+
102
+ it('does not mutate the config it was given', () => {
103
+ const before = structuredClone(raw);
104
+ resolveAutoConfig(resolved, raw, new Set(['gap', 'grid.size']));
105
+ expect(raw).toEqual(before);
106
+ });
107
+ });