@weasel-js/labkit 1.6.0 → 1.6.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 (162) hide show
  1. package/dist/_dts/{CanvasStackContext-DQgcH1ny.d.ts → CanvasStackContext-Wo6OwIVM.d.ts} +1 -1
  2. package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
  3. package/dist/_dts/{LayerList.d-2c9yup5Y.d.ts → LayerList.d-BoELAMsM.d.ts} +2 -2
  4. package/dist/_dts/{index-DINmCuLA.d.ts → PropertyField.d-D0ndfIIt.d.ts} +34 -83
  5. package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Cr8-zoup.d.ts} +4 -5
  6. package/dist/_dts/{usePanZoom-Bq73pwFe.d.ts → Stage-DWBBi8IU.d.ts} +80 -35
  7. package/dist/_dts/{index-B0--8PEM.d.ts → index-CKyX0ME_.d.ts} +12 -13
  8. package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-B7yBTTF9.d.ts} +699 -649
  9. package/dist/_dts/{types-B7y8hX1D.d.ts → types-DTuViBvc.d.ts} +20 -1
  10. package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CMaV6UD4.d.ts} +2 -3
  11. package/dist/canvas/index.d.ts +39 -7
  12. package/dist/canvas/index.js +5 -3
  13. package/dist/chrome/index.d.ts +4 -5
  14. package/dist/chrome/index.js +8 -6
  15. package/dist/chunk-24W5VEJW.js +18 -0
  16. package/dist/chunk-24W5VEJW.js.map +1 -0
  17. package/dist/chunk-3RY6WL5T.js +38 -0
  18. package/dist/chunk-3RY6WL5T.js.map +1 -0
  19. package/dist/{chunk-H5VIV7MQ.js → chunk-4TT3PNPR.js} +60 -51
  20. package/dist/chunk-4TT3PNPR.js.map +1 -0
  21. package/dist/{chunk-ZORVNOKX.js → chunk-6FLXL3I7.js} +9 -145
  22. package/dist/chunk-6FLXL3I7.js.map +1 -0
  23. package/dist/{chunk-HFRE4QQV.js → chunk-ASOK76ZH.js} +53 -22
  24. package/dist/chunk-ASOK76ZH.js.map +1 -0
  25. package/dist/chunk-AWQG7FCC.js +19 -0
  26. package/dist/chunk-AWQG7FCC.js.map +1 -0
  27. package/dist/chunk-CGHF6RZV.js +300 -0
  28. package/dist/chunk-CGHF6RZV.js.map +1 -0
  29. package/dist/chunk-DWMSB4CA.js +228 -0
  30. package/dist/chunk-DWMSB4CA.js.map +1 -0
  31. package/dist/{chunk-USO6TERP.js → chunk-GHGO52SO.js} +11 -9
  32. package/dist/chunk-GHGO52SO.js.map +1 -0
  33. package/dist/{chunk-MOLIUNVH.js → chunk-HKV7LRHY.js} +4 -4
  34. package/dist/{chunk-MOLIUNVH.js.map → chunk-HKV7LRHY.js.map} +1 -1
  35. package/dist/{chunk-VQ47KWD5.js → chunk-IRTGUQ5O.js} +5 -4
  36. package/dist/chunk-IRTGUQ5O.js.map +1 -0
  37. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  38. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  39. package/dist/chunk-KHK5FGER.js +145 -0
  40. package/dist/chunk-KHK5FGER.js.map +1 -0
  41. package/dist/chunk-NEXR37YJ.js +144 -0
  42. package/dist/chunk-NEXR37YJ.js.map +1 -0
  43. package/dist/{chunk-BVL7Y574.js → chunk-O6FFFOHS.js} +14 -6
  44. package/dist/chunk-O6FFFOHS.js.map +1 -0
  45. package/dist/chunk-QGMOA2YE.js +731 -0
  46. package/dist/chunk-QGMOA2YE.js.map +1 -0
  47. package/dist/{chunk-ERFMC6WU.js → chunk-S4NCI32V.js} +19 -10
  48. package/dist/chunk-S4NCI32V.js.map +1 -0
  49. package/dist/{chunk-3FI7Y4R4.js → chunk-SLAQPUI4.js} +4 -4
  50. package/dist/{chunk-3FI7Y4R4.js.map → chunk-SLAQPUI4.js.map} +1 -1
  51. package/dist/{chunk-AWH3SRPW.js → chunk-TJ77A4A3.js} +3 -31
  52. package/dist/chunk-TJ77A4A3.js.map +1 -0
  53. package/dist/{chunk-WUJXJC5O.js → chunk-TYN6MLXX.js} +691 -1395
  54. package/dist/chunk-TYN6MLXX.js.map +1 -0
  55. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  56. package/dist/chunk-WPLAANH4.js.map +1 -0
  57. package/dist/config/index.d.ts +36 -35
  58. package/dist/config/index.js +4 -5
  59. package/dist/controls/index.d.ts +91 -7
  60. package/dist/controls/index.js +5 -4
  61. package/dist/dragdrop/index.d.ts +8 -6
  62. package/dist/dragdrop/index.js +3 -2
  63. package/dist/index.d.ts +50 -77
  64. package/dist/index.js +138 -254
  65. package/dist/index.js.map +1 -1
  66. package/dist/layers/index.d.ts +3 -4
  67. package/dist/layers/index.js +2 -1
  68. package/dist/loupe/index.d.ts +19 -16
  69. package/dist/loupe/index.js +5 -3
  70. package/dist/overview/index.d.ts +56 -0
  71. package/dist/overview/index.js +288 -0
  72. package/dist/overview/index.js.map +1 -0
  73. package/dist/passthrough/weasel-ui.d.ts +38 -38
  74. package/dist/passthrough/weasel-ui.js +2 -1
  75. package/dist/primitives/index.d.ts +19 -43
  76. package/dist/primitives/index.js +5 -3
  77. package/dist/state/index.d.ts +7 -7
  78. package/dist/state/index.js +4 -5
  79. package/dist/state/index.js.map +1 -1
  80. package/dist/styles.css +63 -1
  81. package/dist/undo/index.d.ts +3 -3
  82. package/package.json +13 -9
  83. package/src/annotations/index.ts +1 -0
  84. package/src/annotations/store.test.ts +26 -0
  85. package/src/annotations/store.ts +25 -6
  86. package/src/annotations/toolMap.test.ts +37 -2
  87. package/src/annotations/toolMap.ts +13 -0
  88. package/src/annotations/types.ts +27 -0
  89. package/src/canvas/AGENTS.md +8 -10
  90. package/src/canvas/CameraInput.test.tsx +136 -0
  91. package/src/canvas/CameraInput.tsx +311 -0
  92. package/src/canvas/CanvasStack.tsx +42 -58
  93. package/src/canvas/LinkedCursor.less +16 -0
  94. package/src/canvas/LinkedCursor.tsx +54 -0
  95. package/src/canvas/Stage.tsx +42 -37
  96. package/src/canvas/cameraView.test.ts +60 -0
  97. package/src/canvas/cameraView.ts +61 -0
  98. package/src/canvas/index.ts +11 -2
  99. package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
  100. package/src/config/builder.ts +3 -3
  101. package/src/config/entry.test.ts +3 -1
  102. package/src/config/fromConfigField.ts +1 -0
  103. package/src/config/index.ts +4 -1
  104. package/src/config/resolve.ts +11 -1
  105. package/src/config/sectionTree.test.ts +12 -6
  106. package/src/config/sectionTree.ts +15 -5
  107. package/src/config/types.ts +21 -0
  108. package/src/controls/ControlPanel.test.tsx +12 -2
  109. package/src/controls/ControlPanel.tsx +28 -9
  110. package/src/controls/inDialog.test.tsx +18 -0
  111. package/src/controls/inDialog.tsx +2 -10
  112. package/src/index.test.ts +1 -1
  113. package/src/index.ts +8 -4
  114. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  115. package/src/instrument/types.ts +10 -1
  116. package/src/lab/Lab.nebula.test.tsx +11 -2
  117. package/src/lab/Lab.test.tsx +3 -1
  118. package/src/lab/Lab.tsx +4 -4
  119. package/src/loupe/AGENTS.md +7 -6
  120. package/src/loupe/LoupeGestures.tsx +14 -9
  121. package/src/loupe/TrialLoupe.tsx +5 -5
  122. package/src/loupe/index.ts +1 -0
  123. package/src/loupe/loupeActions.ts +6 -2
  124. package/src/overview/AGENTS.md +38 -0
  125. package/src/overview/OverviewMarks.test.tsx +100 -0
  126. package/src/overview/OverviewMarks.tsx +93 -0
  127. package/src/overview/TrialOverview.less +53 -0
  128. package/src/overview/TrialOverview.stories.tsx +50 -0
  129. package/src/overview/TrialOverview.test.tsx +116 -0
  130. package/src/overview/TrialOverview.tsx +374 -0
  131. package/src/overview/index.ts +2 -0
  132. package/src/passthrough/weasel-ui.ts +7 -0
  133. package/src/specimen/Specimen.tsx +19 -2
  134. package/src/state/labRecords.test.ts +4 -1
  135. package/src/state/store.test.ts +4 -4
  136. package/src/state/useTrialState.test.tsx +1 -1
  137. package/src/styles.less +2 -0
  138. package/src/theme/Interstellar.stories.tsx +1 -5
  139. package/src/tools/labTool.ts +6 -2
  140. package/src/trial/Trial.annotations.test.tsx +16 -0
  141. package/src/trial/Trial.loupe.test.tsx +73 -1
  142. package/src/trial/Trial.stories.tsx +2 -0
  143. package/src/trial/Trial.tsx +17 -1
  144. package/src/trial/trialOps.test.ts +4 -4
  145. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
  146. package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
  147. package/dist/chunk-2AYGEN57.js +0 -32
  148. package/dist/chunk-2AYGEN57.js.map +0 -1
  149. package/dist/chunk-AWH3SRPW.js.map +0 -1
  150. package/dist/chunk-BVL7Y574.js.map +0 -1
  151. package/dist/chunk-ERFMC6WU.js.map +0 -1
  152. package/dist/chunk-H5VIV7MQ.js.map +0 -1
  153. package/dist/chunk-HFRE4QQV.js.map +0 -1
  154. package/dist/chunk-RS3HRQWU.js.map +0 -1
  155. package/dist/chunk-TT5C5S6R.js +0 -368
  156. package/dist/chunk-TT5C5S6R.js.map +0 -1
  157. package/dist/chunk-USO6TERP.js.map +0 -1
  158. package/dist/chunk-VQ47KWD5.js.map +0 -1
  159. package/dist/chunk-WUJXJC5O.js.map +0 -1
  160. package/dist/chunk-ZORVNOKX.js.map +0 -1
  161. package/src/canvas/usePanZoom.test.ts +0 -244
  162. package/src/canvas/usePanZoom.ts +0 -147
@@ -0,0 +1,61 @@
1
+ /**
2
+ * labkit's camera as a weasel `View`, so core's viewport actions drive it.
3
+ *
4
+ * labkit places a world point on screen at
5
+ * `originPx + pan + zoom * (x, yDir * y)`. A weasel `View` places one at
6
+ * `(p - (x, y)) * scale`. Over *frame-local* coordinates — the world with `y`
7
+ * multiplied by `yDir` — the two are the same affine map, which is all a pan
8
+ * or a zoom about a screen point needs.
9
+ */
10
+ import type { View } from '@weasel-js/core';
11
+ import type { Point, ViewTransform } from '../instrument/types';
12
+ import { normalize2DView } from '../state/view';
13
+ import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
14
+
15
+ /** The weasel `View` equal to `view` under `frame`. */
16
+ export function toCameraView(view: ViewTransform, frame: WorldFrame = DEFAULT_FRAME): View {
17
+ const v = normalize2DView(view);
18
+ return {
19
+ x: -(frame.originPx.x + v.pan.x) / v.zoom,
20
+ y: -(frame.originPx.y + v.pan.y) / v.zoom,
21
+ scale: { x: v.zoom, y: v.zoom },
22
+ };
23
+ }
24
+
25
+ /** The labkit camera equal to weasel `view` under `frame`. */
26
+ export function fromCameraView(view: View, frame: WorldFrame = DEFAULT_FRAME): ViewTransform {
27
+ const zoom = view.scale.x;
28
+ return {
29
+ zoom,
30
+ pan: { x: -view.x * zoom - frame.originPx.x, y: -view.y * zoom - frame.originPx.y },
31
+ };
32
+ }
33
+
34
+ /** A frame-local point in the instrument's world, and back — the map is its
35
+ * own inverse. */
36
+ export function frameLocalToWorld(p: Point, frame: WorldFrame = DEFAULT_FRAME): Point {
37
+ return { x: p.x, y: p.y * frame.yDir };
38
+ }
39
+
40
+ /**
41
+ * `next` with its zoom held to [`min`, `max`], about the screen point the step
42
+ * from `prev` was anchored on, so a zoom that hits a bound stays anchored where
43
+ * the pointer is. A pan (equal scales) passes through.
44
+ */
45
+ export function clampZoomAbout(prev: View, next: View, min: number, max: number): View {
46
+ const s0 = prev.scale.x;
47
+ const s1 = next.scale.x;
48
+ const clamped = Math.min(max, Math.max(min, s1));
49
+ if (clamped === s1) return next;
50
+ if (s0 === s1) return { ...next, scale: { x: clamped, y: clamped } };
51
+ // The screen point both views agree on: p / s0 + x0 = p / s1 + x1.
52
+ const inv = 1 / s0 - 1 / s1;
53
+ const px = (next.x - prev.x) / inv;
54
+ const py = (next.y - prev.y) / inv;
55
+ // The same point under the clamped scale.
56
+ return {
57
+ x: prev.x + px / s0 - px / clamped,
58
+ y: prev.y + py / s0 - py / clamped,
59
+ scale: { x: clamped, y: clamped },
60
+ };
61
+ }
@@ -1,3 +1,12 @@
1
+ export type { CameraContextValue, CameraInputProps, CameraViewOptions } from './CameraInput';
2
+ export {
3
+ CameraContext,
4
+ CameraInput,
5
+ CameraScope,
6
+ CameraScopeContext,
7
+ STAGE_VIEW_ID,
8
+ useCameraView,
9
+ } from './CameraInput';
1
10
  export type { CameraWheelSlot } from './CameraWheelContext';
2
11
  export { CameraWheelContext } from './CameraWheelContext';
3
12
  export type { CanvasStackProps } from './CanvasStack';
@@ -6,11 +15,11 @@ export type { CanvasStackContextValue, CanvasStackSurface } from './CanvasStackC
6
15
  export { CanvasStackContext } from './CanvasStackContext';
7
16
  export type { ZoomAtOptions } from './camera';
8
17
  export { centerOn, zoomAt } from './camera';
18
+ export { clampZoomAbout, frameLocalToWorld, fromCameraView, toCameraView } from './cameraView';
9
19
  export { screenToWorld, worldToScreen } from './canvasCoords';
20
+ export { LinkedCursor } from './LinkedCursor';
10
21
  export type { StageProps } from './Stage';
11
22
  export { fitStage, Stage } from './Stage';
12
23
  export type { CanvasLayerDescriptor } from './useLayerScheduler';
13
- export type { PanZoomHandlers, UsePanZoomOptions } from './usePanZoom';
14
- export { usePanZoom } from './usePanZoom';
15
24
  export type { ViewportSize, WorldFrame, WorldSpec } from './worldSpec';
16
25
  export { applyCamera, DEFAULT_FRAME, resolveFrame } from './worldSpec';
@@ -3,13 +3,20 @@ import { describe, expect, it } from 'vitest';
3
3
  import type { RegionContribution, TrialChromeContext } from '../types';
4
4
  import { SidebarRegion } from './SidebarRegion';
5
5
 
6
- const ctx = { collapsedSections: {}, setSectionCollapsed: () => {} } as unknown as TrialChromeContext;
6
+ const ctx = {
7
+ collapsedSections: {},
8
+ setSectionCollapsed: () => {},
9
+ } as unknown as TrialChromeContext;
7
10
 
8
11
  describe('<SidebarRegion> stance and tone', () => {
9
12
  it('puts a section’s stance and tone on the section', () => {
10
13
  const contributions: RegionContribution<TrialChromeContext>[] = [
11
14
  { id: 'a', region: 'sidebar', item: { title: 'Plain', body: 'x' } },
12
- { id: 'b', region: 'sidebar', item: { title: 'Debug', body: 'y', stance: 'debug', tone: '#224a63' } },
15
+ {
16
+ id: 'b',
17
+ region: 'sidebar',
18
+ item: { title: 'Debug', body: 'y', stance: 'debug', tone: '#224a63' },
19
+ },
13
20
  ];
14
21
  const { container } = render(<SidebarRegion contributions={contributions} ctx={ctx} />);
15
22
  const [plain, debug] = [...container.querySelectorAll<HTMLElement>('.lk-sidebar-section')];
@@ -1,5 +1,4 @@
1
1
  import type { PrefLeaf, PrefNumberFormat, PrefNumberUnit } from '@weasel-js/ui';
2
- import { type InDialogOptions, inDialog } from '../controls/inDialog';
3
2
  import { type Auto, isAuto } from './auto';
4
3
  import type {
5
4
  Annotations,
@@ -12,6 +11,7 @@ import type {
12
11
  ConfigSchema,
13
12
  ConfigShape,
14
13
  ControlRenderer,
14
+ InDialogOptions,
15
15
  InferConfig,
16
16
  NodeOptions,
17
17
  SectionOption,
@@ -89,13 +89,13 @@ export abstract class BaseNode<T> implements ConfigNode<T> {
89
89
 
90
90
  /** Draw this one row yourself, keeping the kind, default and validation. */
91
91
  render(renderer: ControlRenderer): this {
92
- return this.opt({ render: renderer });
92
+ return this.opt({ render: renderer, dialog: undefined });
93
93
  }
94
94
 
95
95
  /** Draw this row as a button that opens a dialog, with `body` as the
96
96
  * dialog's content — for an editor too big for the row. */
97
97
  dialog(body: ControlRenderer, opts?: InDialogOptions): this {
98
- return this.render(inDialog(body, opts));
98
+ return this.opt({ dialog: { ...opts, body }, render: undefined });
99
99
  }
100
100
 
101
101
  /**
@@ -39,7 +39,9 @@ describe('@weasel-js/labkit/config', () => {
39
39
  // A forge frame loads this entry for every story, and ui's barrel is 167 modules on the dev server.
40
40
  it('loads nothing from @weasel-js/ui at runtime', () => {
41
41
  const importers = [...walk(resolve(__dirname, 'index.ts'))].filter((file) =>
42
- [...readFileSync(file, 'utf8').matchAll(IMPORT)].some((m) => (m[1] ?? m[2] ?? m[3])?.startsWith('@weasel-js/ui')),
42
+ [...readFileSync(file, 'utf8').matchAll(IMPORT)].some((m) =>
43
+ (m[1] ?? m[2] ?? m[3])?.startsWith('@weasel-js/ui'),
44
+ ),
43
45
  );
44
46
  expect(importers).toEqual([]);
45
47
  });
@@ -70,5 +70,6 @@ export function fromConfigFields(fields: readonly ConfigField[]): ResolvedConfig
70
70
  sections: [],
71
71
  showIf: new Map(),
72
72
  renderers: {},
73
+ dialogs: {},
73
74
  };
74
75
  }
@@ -23,8 +23,8 @@ export {
23
23
  withValueAtPath,
24
24
  } from './path';
25
25
  export { resolveConfigSchema } from './resolve';
26
- export { type SectionTree, sectionTree } from './sectionTree';
27
26
  export { applyRules, builtinRules, titleCase } from './rules';
27
+ export { type SectionTree, sectionTree } from './sectionTree';
28
28
  export type {
29
29
  Annotations,
30
30
  BranchAnnotations,
@@ -40,12 +40,15 @@ export type {
40
40
  ConfigSchema,
41
41
  ConfigShape,
42
42
  ControlRenderer,
43
+ DialogSpec,
43
44
  EntryValue,
45
+ InDialogOptions,
44
46
  InferConfig,
45
47
  LeafPatch,
46
48
  NodeOptions,
47
49
  NodeValue,
48
50
  ResolvedConfig,
51
+ SectionOption,
49
52
  SectionSpec,
50
53
  ValueAtPath,
51
54
  } from './types';
@@ -7,6 +7,7 @@ import type {
7
7
  ConfigSchema,
8
8
  ConfigShape,
9
9
  ControlRenderer,
10
+ DialogSpec,
10
11
  LeafPatch,
11
12
  ResolvedConfig,
12
13
  SectionSpec,
@@ -25,6 +26,7 @@ interface Sink {
25
26
  sections: SectionSpec[];
26
27
  showIf: Map<string, (config: Record<string, unknown>) => boolean>;
27
28
  renderers: Record<string, ControlRenderer>;
29
+ dialogs: Record<string, DialogSpec>;
28
30
  chain: readonly ConfigRule[];
29
31
  }
30
32
 
@@ -45,10 +47,17 @@ export function resolveConfigSchema<TC>(
45
47
  sections: [],
46
48
  showIf: new Map(),
47
49
  renderers: {},
50
+ dialogs: {},
48
51
  chain: [...rules, ...builtinRules],
49
52
  };
50
53
  const group = resolveShape(schema.nodes, '', '', sink);
51
- return { group, sections: sink.sections, showIf: sink.showIf, renderers: sink.renderers };
54
+ return {
55
+ group,
56
+ sections: sink.sections,
57
+ showIf: sink.showIf,
58
+ renderers: sink.renderers,
59
+ dialogs: sink.dialogs,
60
+ };
52
61
  }
53
62
 
54
63
  /** A section while it is still being filled. */
@@ -81,6 +90,7 @@ function resolveShape(shape: ConfigShape, at: string, name: string, sink: Sink):
81
90
  }
82
91
  if (predicate) sink.showIf.set(path, predicate);
83
92
  if (!isConfigBranch(entry) && entry.options.render) sink.renderers[path] = entry.options.render;
93
+ if (!isConfigBranch(entry) && entry.options.dialog) sink.dialogs[path] = entry.options.dialog;
84
94
  }
85
95
 
86
96
  return { name, children };
@@ -70,14 +70,19 @@ describe('sectionTree', () => {
70
70
  'weight',
71
71
  ]);
72
72
  const off = sectionTree(resolved, { lineNumbers: false, listing: { dim: 1, weight: 0 } });
73
- expect(Object.keys(groupAt(groupAt(off.group, 'view'), 'listing').children)).toEqual(['weight']);
73
+ expect(Object.keys(groupAt(groupAt(off.group, 'view'), 'listing').children)).toEqual([
74
+ 'weight',
75
+ ]);
74
76
  });
75
77
 
76
78
  it('drops a section nothing in it is showing, rather than opening it onto nothing', () => {
77
79
  const resolved = resolveConfigSchema(
78
80
  f.schema({
79
81
  mode: f.enum<string>('a', ['a', 'b']).section('View'),
80
- glow: f.number(1).section('Diff').showIf((c) => c.mode === 'b'),
82
+ glow: f
83
+ .number(1)
84
+ .section('Diff')
85
+ .showIf((c) => c.mode === 'b'),
81
86
  }),
82
87
  );
83
88
  expect(Object.keys(sectionTree(resolved, { mode: 'b', glow: 1 }).group.children)).toEqual([
@@ -91,7 +96,10 @@ describe('sectionTree', () => {
91
96
 
92
97
  it('keeps a hidden leaf out unless asked for it', () => {
93
98
  const resolved = resolveConfigSchema(
94
- f.schema({ seed: f.number(1).hidden().section('Debug'), route: f.boolean(true).section('Debug') }),
99
+ f.schema({
100
+ seed: f.number(1).hidden().section('Debug'),
101
+ route: f.boolean(true).section('Debug'),
102
+ }),
95
103
  );
96
104
  expect(Object.keys(groupAt(sectionTree(resolved, {}).group, 'debug').children)).toEqual([
97
105
  'route',
@@ -134,9 +142,7 @@ describe('sectionTree', () => {
134
142
  it('ignores a section nested under a group, which buckets rows in a pane', () => {
135
143
  const resolved = resolveConfigSchema(
136
144
  f.schema({
137
- cards: f
138
- .group({ floor: f.number(1).section('Size') })
139
- .section('View'),
145
+ cards: f.group({ floor: f.number(1).section('Size') }).section('View'),
140
146
  }),
141
147
  );
142
148
  const { group } = sectionTree(resolved, { cards: { floor: 1 } });
@@ -1,4 +1,4 @@
1
- import { isPrefLeaf, type PrefGroup } from '@weasel-js/ui';
1
+ import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
2
2
  import { valueAtPath } from './path';
3
3
  import type { ResolvedConfig } from './types';
4
4
  import { isLeafVisible } from './visible';
@@ -22,10 +22,20 @@ export interface SectionTree {
22
22
  pathAt: (railPath: string) => string;
23
23
  }
24
24
 
25
+ /** Structural rather than `isPrefLeaf`, so the config entry stays free of a
26
+ * runtime import from ui. */
27
+ function isLeaf(node: PrefLeaf | PrefGroup): node is PrefLeaf {
28
+ return 'kind' in node;
29
+ }
30
+
25
31
  /** A section label as an object key: no dots, since a dotted path is how every
26
32
  * caller addresses a node, and a label is free to hold one. */
27
33
  function slugOf(label: string, taken: ReadonlySet<string>): string {
28
- const base = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'section';
34
+ const base =
35
+ label
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9]+/g, '-')
38
+ .replace(/^-|-$/g, '') || 'section';
29
39
  if (!taken.has(base)) return base;
30
40
  for (let n = 2; ; n += 1) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`;
31
41
  }
@@ -44,7 +54,7 @@ function prune(
44
54
  for (const [key, child] of Object.entries(group.children)) {
45
55
  const path = at === '' ? key : `${at}.${key}`;
46
56
  if (!isLeafVisible(resolved, path, config, showHidden)) continue;
47
- if (isPrefLeaf(child)) {
57
+ if (isLeaf(child)) {
48
58
  children[key] = child;
49
59
  continue;
50
60
  }
@@ -81,7 +91,7 @@ export function sectionTree(
81
91
  claimed.add(path);
82
92
  const child = resolved.group.children[path];
83
93
  if (!child || !isLeafVisible(resolved, path, config, showHidden)) continue;
84
- if (isPrefLeaf(child)) {
94
+ if (isLeaf(child)) {
85
95
  kept[path] = child;
86
96
  } else {
87
97
  const subtree = prune(resolved, child, path, config, showHidden);
@@ -101,7 +111,7 @@ export function sectionTree(
101
111
  // gathers loose leaves into one item named for the root.
102
112
  for (const [key, child] of Object.entries(resolved.group.children)) {
103
113
  if (claimed.has(key) || !isLeafVisible(resolved, key, config, showHidden)) continue;
104
- if (isPrefLeaf(child)) {
114
+ if (isLeaf(child)) {
105
115
  children[key] = child;
106
116
  } else {
107
117
  const subtree = prune(resolved, child, key, config, showHidden);
@@ -5,6 +5,7 @@ import type {
5
5
  PrefNumberUnit,
6
6
  PrefRenderer,
7
7
  } from '@weasel-js/ui';
8
+ import type { ReactNode } from 'react';
8
9
 
9
10
  /**
10
11
  * Renders the control cell for one config leaf. Identical to weasel-ui's
@@ -104,6 +105,23 @@ export interface ResolvedConfig {
104
105
  showIf: ReadonlyMap<string, (config: Record<string, unknown>) => boolean>;
105
106
  /** Node-level `.render` overrides, keyed by path. */
106
107
  renderers: Readonly<Record<string, ControlRenderer>>;
108
+ /** Node-level `.dialog` rows, keyed by path. */
109
+ dialogs: Readonly<Record<string, DialogSpec>>;
110
+ }
111
+
112
+ /** Options for a row drawn as a button that opens a dialog. */
113
+ export interface InDialogOptions {
114
+ /** What the row's button shows of the value. Defaults to a list joined with
115
+ * commas, or the value itself. */
116
+ summary?: (value: unknown) => ReactNode;
117
+ /** The dialog's heading. Defaults to the row's label. */
118
+ title?: ReactNode;
119
+ }
120
+
121
+ /** A `.dialog` row: `body` is the dialog's content, handed the context a row
122
+ * renderer gets. */
123
+ export interface DialogSpec extends InDialogOptions {
124
+ body: ControlRenderer;
107
125
  }
108
126
 
109
127
  /** How a node names the section it belongs to: the heading, and how that
@@ -121,6 +139,9 @@ export interface SectionOption {
121
139
  /** Per-node extras that do not belong on a `PrefLeaf`. */
122
140
  export interface NodeOptions extends BranchOptions {
123
141
  render?: ControlRenderer;
142
+ /** Draw the row as a button that opens a dialog. Set by `.dialog`; the
143
+ * config entry records it, and `ControlPanel` draws it. */
144
+ dialog?: DialogSpec;
124
145
  /** Errors for this leaf under `config`, the instrument's whole config. */
125
146
  validate?: (leaf: PrefLeaf, config: Record<string, unknown>) => string[];
126
147
  /** Computes this leaf's value while it is auto. Attaching one is what gives
@@ -372,6 +372,7 @@ describe('<ControlPanel> built-in kind coverage', () => {
372
372
  sections: [],
373
373
  showIf: new Map(),
374
374
  renderers: {},
375
+ dialogs: {},
375
376
  });
376
377
 
377
378
  it('declines paint and object with a named row rather than a blank one', () => {
@@ -1075,14 +1076,23 @@ describe('<ControlPanel> title, stance and tone', () => {
1075
1076
  const fields: ConfigField[] = [{ key: 'on', label: 'On', type: 'checkbox', default: true }];
1076
1077
 
1077
1078
  it('renders the bare list when given none of them', () => {
1078
- const { container } = render(<ControlPanel fields={fields} config={{ on: true }} setConfig={vi.fn()} />);
1079
+ const { container } = render(
1080
+ <ControlPanel fields={fields} config={{ on: true }} setConfig={vi.fn()} />,
1081
+ );
1079
1082
  expect(container.firstElementChild?.classList.contains('lk-control-panel')).toBe(true);
1080
1083
  expect(container.querySelector('[data-stance], h2')).toBeNull();
1081
1084
  });
1082
1085
 
1083
1086
  it('wraps its rows in a panel carrying them when asked', () => {
1084
1087
  const { container } = render(
1085
- <ControlPanel title="View" stance="scope" tone={1} fields={fields} config={{ on: true }} setConfig={vi.fn()} />,
1088
+ <ControlPanel
1089
+ title="View"
1090
+ stance="scope"
1091
+ tone={1}
1092
+ fields={fields}
1093
+ config={{ on: true }}
1094
+ setConfig={vi.fn()}
1095
+ />,
1086
1096
  );
1087
1097
  const panel = container.firstElementChild as HTMLElement;
1088
1098
  expect(panel.dataset.stance).toBe('scope');
@@ -6,8 +6,8 @@ import {
6
6
  type PrefGroup,
7
7
  type PrefLeaf,
8
8
  type PropertyAlign,
9
- type PropertyControlProps,
10
9
  PropertyControl,
10
+ type PropertyControlProps,
11
11
  type PropertyDensity,
12
12
  PropertyField,
13
13
  PropertyGroup,
@@ -26,7 +26,7 @@ import { fromConfigFields } from '../config/fromConfigField';
26
26
  import { schemaNodeAtPath, valueAtPath } from '../config/path';
27
27
  import type { ControlRenderer, ResolvedConfig, SectionSpec } from '../config/types';
28
28
  import { isLeafVisible } from '../config/visible';
29
- import { summarizeValue } from './inDialog';
29
+ import { inDialog, summarizeValue } from './inDialog';
30
30
  import type { ConfigField } from './types';
31
31
 
32
32
  /** How a panel packs its rows into the two-column property grid.
@@ -243,7 +243,13 @@ export function ControlPanel<TC extends Record<string, unknown>>({
243
243
  if (pair === undefined) return undefined;
244
244
  if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden))
245
245
  return undefined;
246
- if (renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[found.kind]) return undefined;
246
+ if (
247
+ renderers?.[path] ??
248
+ resolved.renderers[path] ??
249
+ resolved.dialogs[path] ??
250
+ renderers?.[found.kind]
251
+ )
252
+ return undefined;
247
253
  if (!isBuiltinToolPref(found)) return undefined;
248
254
  if (found.kind === 'paint' || found.kind === 'object' || isSliderLeaf(found)) return undefined;
249
255
  return { leaf: found, pair };
@@ -405,8 +411,13 @@ function ControlRow<TC extends Record<string, unknown>>({
405
411
  const autoProps = { auto: isAutoRow, onAutoChange };
406
412
 
407
413
  // Most specific wins, and within a tier the lab's entry beats the
408
- // instrument's: controls[path] -> node .render -> controls[kind] -> built-in.
409
- const custom = renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[leaf.kind];
414
+ // instrument's: controls[path] -> node .render/.dialog -> controls[kind] -> built-in.
415
+ const dialog = resolved.dialogs[path];
416
+ const custom =
417
+ renderers?.[path] ??
418
+ resolved.renderers[path] ??
419
+ (dialog ? inDialog(dialog.body, dialog) : undefined) ??
420
+ renderers?.[leaf.kind];
410
421
  // A custom row places itself like any other; one that needs the full width
411
422
  // says so with `<PropertyRow span>`, which is the same opt-out a built-in has.
412
423
  if (custom) return custom({ path, pref: leaf, value, setValue: write, auto: isAutoRow, setAuto });
@@ -442,7 +453,8 @@ function ControlRow<TC extends Record<string, unknown>>({
442
453
  }
443
454
 
444
455
  const field = labField(leaf, value, write);
445
- if (field === null) return <UnwiredRow label={label} kind={leaf.kind} description={description} />;
456
+ if (field === null)
457
+ return <UnwiredRow label={label} kind={leaf.kind} description={description} />;
446
458
  switch (field.kind) {
447
459
  case 'string':
448
460
  return <DebouncedTextRow leaf={leaf} field={field} span={wide} {...row} />;
@@ -479,7 +491,10 @@ function labField(
479
491
  unit: extra<string>(leaf, 'suffix') ?? field.unit,
480
492
  };
481
493
  case 'boolean':
482
- return { ...field, control: extra<string>(leaf, 'control') === 'switch' ? 'switch' : 'checkbox' };
494
+ return {
495
+ ...field,
496
+ control: extra<string>(leaf, 'control') === 'switch' ? 'switch' : 'checkbox',
497
+ };
483
498
  case 'enum':
484
499
  return { ...field, control: extra<string>(leaf, 'control') === 'radio' ? 'radio' : 'select' };
485
500
  case 'string':
@@ -605,7 +620,9 @@ function PairCell({
605
620
  if (field === null) return null;
606
621
  switch (field.kind) {
607
622
  case 'string':
608
- return <PropertyControl {...field} name={leaf.name} value={text.local} onChange={text.type} />;
623
+ return (
624
+ <PropertyControl {...field} name={leaf.name} value={text.local} onChange={text.type} />
625
+ );
609
626
  case 'number':
610
627
  // The pair names the row, so a cell has no room for a unit.
611
628
  return <PropertyControl {...field} name={leaf.name} control="input" unit={undefined} />;
@@ -618,7 +635,9 @@ function PairCell({
618
635
  {...field}
619
636
  name={leaf.name}
620
637
  alpha={undefined}
621
- onChange={(rgb: string) => write(field.alpha ? withAlpha01(rgb, getAlpha01(stored)) : rgb)}
638
+ onChange={(rgb: string) =>
639
+ write(field.alpha ? withAlpha01(rgb, getAlpha01(stored)) : rgb)
640
+ }
622
641
  />
623
642
  );
624
643
  }
@@ -69,6 +69,24 @@ describe('.dialog', () => {
69
69
  render(<Panel schema={schema} />);
70
70
  expect(screen.getByRole('button', { name: 'Edit Pts' }).textContent).toContain('3 points');
71
71
  });
72
+
73
+ it('gives way to a later .render, and takes over from an earlier one', () => {
74
+ const schema = f.schema({
75
+ a: f
76
+ .string('x')
77
+ .dialog(() => null)
78
+ .render(() => <span>row a</span>),
79
+ b: f
80
+ .string('y')
81
+ .render(() => <span>row b</span>)
82
+ .dialog(() => null),
83
+ });
84
+ render(<Panel schema={schema} />);
85
+ expect(screen.getByText('row a')).toBeTruthy();
86
+ expect(screen.queryByRole('button', { name: 'Edit A' })).toBeNull();
87
+ expect(screen.queryByText('row b')).toBeNull();
88
+ expect(screen.getByRole('button', { name: 'Edit B' })).toBeTruthy();
89
+ });
72
90
  });
73
91
 
74
92
  describe('summarizeValue', () => {
@@ -1,15 +1,7 @@
1
1
  import { DialogRow } from '@weasel-js/ui';
2
- import type { ReactNode } from 'react';
3
- import type { ControlRenderer } from '../config/types';
2
+ import type { ControlRenderer, InDialogOptions } from '../config/types';
4
3
 
5
- /** Options for {@link inDialog}. */
6
- export interface InDialogOptions {
7
- /** What the row's button shows of the value. Defaults to a list joined with
8
- * commas, or the value itself. */
9
- summary?: (value: unknown) => ReactNode;
10
- /** The dialog's heading. Defaults to the row's label. */
11
- title?: ReactNode;
12
- }
4
+ export type { InDialogOptions };
13
5
 
14
6
  /** The one-line summary a dialog row shows when it is given none. */
15
7
  export function summarizeValue(value: unknown): string {
package/src/index.test.ts CHANGED
@@ -72,7 +72,7 @@ describe('surface, job and orbit entry points', () => {
72
72
  expect(typeof kit.annotationsFromJSON).toBe('function');
73
73
  expect(typeof kit.seenFrom).toBe('function');
74
74
  expect(typeof kit.fracToWorld).toBe('function');
75
- expect(typeof kit.usePanZoom).toBe('function');
75
+ expect(typeof kit.CameraInput).toBe('function');
76
76
  expect(typeof kit.useJob).toBe('function');
77
77
  expect(typeof kit.as2DView).toBe('function');
78
78
  expect(kit.SurfaceContext).toBeDefined();
package/src/index.ts CHANGED
@@ -79,6 +79,7 @@ export type {
79
79
  AnnotationsCapability,
80
80
  AnnotationTarget,
81
81
  AnnotationTargetInfo,
82
+ AnnotationToolId,
82
83
  CaptureDeps,
83
84
  CaptureOptions,
84
85
  CaptureResult,
@@ -129,13 +130,12 @@ export {
129
130
  export type { ContentSize, PaneSize } from './annotations/view';
130
131
  export { fitView, fromWeaselView, toWeaselView } from './annotations/view';
131
132
  export type {
133
+ CameraContextValue,
132
134
  CanvasLayerDescriptor,
133
135
  CanvasStackContextValue,
134
136
  CanvasStackProps,
135
137
  CanvasStackSurface,
136
- PanZoomHandlers,
137
138
  StageProps,
138
- UsePanZoomOptions,
139
139
  ViewportSize,
140
140
  WorldFrame,
141
141
  WorldSpec,
@@ -143,6 +143,9 @@ export type {
143
143
  } from './canvas';
144
144
  export {
145
145
  applyCamera,
146
+ CameraContext,
147
+ CameraInput,
148
+ CameraScope,
146
149
  CanvasStack,
147
150
  CanvasStackContext,
148
151
  centerOn,
@@ -151,7 +154,6 @@ export {
151
154
  resolveFrame,
152
155
  Stage,
153
156
  screenToWorld,
154
- usePanZoom,
155
157
  worldToScreen,
156
158
  zoomAt,
157
159
  } from './canvas';
@@ -173,7 +175,6 @@ export {
173
175
  ValueNode,
174
176
  } from './config/builder';
175
177
  export { fromConfigFields } from './config/fromConfigField';
176
- export { type SectionTree, sectionTree } from './config/sectionTree';
177
178
  export {
178
179
  fillConfigDefaults,
179
180
  hasConfigPath,
@@ -183,6 +184,7 @@ export {
183
184
  } from './config/path';
184
185
  export { resolveConfigSchema } from './config/resolve';
185
186
  export { applyRules, builtinRules, titleCase } from './config/rules';
187
+ export { type SectionTree, sectionTree } from './config/sectionTree';
186
188
  export type {
187
189
  Annotations,
188
190
  BranchAnnotations,
@@ -198,12 +200,14 @@ export type {
198
200
  ConfigSchema,
199
201
  ConfigShape,
200
202
  ControlRenderer,
203
+ DialogSpec,
201
204
  EntryValue,
202
205
  InferConfig,
203
206
  LeafPatch,
204
207
  NodeOptions,
205
208
  NodeValue,
206
209
  ResolvedConfig,
210
+ SectionOption,
207
211
  SectionSpec,
208
212
  ValueAtPath,
209
213
  } from './config/types';
@@ -1,4 +1,5 @@
1
1
  import { render } from '@testing-library/react';
2
+ import { createPointerStore } from '@weasel-js/core';
2
3
  import type { ReactElement } from 'react';
3
4
  import { describe, expect, it, vi } from 'vitest';
4
5
  import { DEFAULT_VIEW } from '../state/view';
@@ -94,6 +95,7 @@ describe('SineWave instrument smoke test', () => {
94
95
  setZoom: vi.fn(),
95
96
  activeToolId: null,
96
97
  visibleLayers: [],
98
+ pointer: createPointerStore(),
97
99
  },
98
100
  emit: vi.fn(),
99
101
  };
@@ -1,3 +1,4 @@
1
+ import type { PointerContextValue } from '@weasel-js/core';
1
2
  import type { ReactNode } from 'react';
2
3
  import type { AnnotationsCapability } from '../annotations/types';
3
4
  import type { ViewportSize, WorldSpec } from '../canvas/worldSpec';
@@ -33,6 +34,14 @@ export interface RenderContext<TS = unknown, TC = unknown> {
33
34
  * hidden layer's `draw`, so this is the only way to know what was
34
35
  * painted — a legend listing rows the view did not draw needs it. */
35
36
  visibleLayers: readonly string[];
37
+ /**
38
+ * Where the pointer is over this trial, in the instrument's world:
39
+ * `get()` answers `{ worldX, worldY, viewId }`, where `viewId` is
40
+ * `'stage'` over the trial's own camera and `'overview'` over its
41
+ * `<TrialOverview>`, or `null` when it is over neither. A key handler
42
+ * reading it works over either view.
43
+ */
44
+ pointer: PointerContextValue;
36
45
  };
37
46
  emit: (event: string) => void;
38
47
  /** Present only when the instrument declares a `job`. */
@@ -61,7 +70,7 @@ export interface CanvasCapability<TS = unknown, TC = unknown> {
61
70
  * size, so an instrument can frame content it can only place in terms of
62
71
  * the viewport; until then the trial's view is `null`. */
63
72
  initialView?: ViewTransform | ((size: ViewportSize) => ViewTransform);
64
- /** Widens `usePanZoom`'s default clamp; the opening zoom stays reachable
73
+ /** Widens the camera's default zoom clamp; the opening zoom stays reachable
65
74
  * regardless of these. */
66
75
  minZoom?: number;
67
76
  maxZoom?: number;