@weasel-js/labkit 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/dist/_dts/{CanvasStackContext-DQgcH1ny.d.ts → CanvasStackContext-D2jnIwfk.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-DiuEArKq.d.ts} +4 -4
  4. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +79 -0
  5. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +141 -0
  6. package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Bensr_MT.d.ts} +15 -6
  7. package/dist/_dts/Stage-CCWfQQWM.d.ts +202 -0
  8. package/dist/_dts/index-B2Yj5aA0.d.ts +274 -0
  9. package/dist/_dts/{index-B0--8PEM.d.ts → index-CsYqUHD3.d.ts} +12 -13
  10. package/dist/_dts/{types-B7y8hX1D.d.ts → types-CRPSGFAp.d.ts} +33 -5
  11. package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-CSm9jgol.d.ts} +700 -650
  12. package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CTZPgeZ7.d.ts} +2 -3
  13. package/dist/canvas/index.d.ts +40 -8
  14. package/dist/canvas/index.js +5 -3
  15. package/dist/chrome/index.d.ts +5 -6
  16. package/dist/chrome/index.js +9 -7
  17. package/dist/chunk-24W5VEJW.js +18 -0
  18. package/dist/chunk-24W5VEJW.js.map +1 -0
  19. package/dist/chunk-275JRRVD.js +728 -0
  20. package/dist/chunk-275JRRVD.js.map +1 -0
  21. package/dist/chunk-3RY6WL5T.js +38 -0
  22. package/dist/chunk-3RY6WL5T.js.map +1 -0
  23. package/dist/chunk-4LJSC7MB.js +143 -0
  24. package/dist/chunk-4LJSC7MB.js.map +1 -0
  25. package/dist/{chunk-3FI7Y4R4.js → chunk-7BO7HNGF.js} +5 -5
  26. package/dist/{chunk-3FI7Y4R4.js.map → chunk-7BO7HNGF.js.map} +1 -1
  27. package/dist/chunk-AWQG7FCC.js +19 -0
  28. package/dist/chunk-AWQG7FCC.js.map +1 -0
  29. package/dist/chunk-CZZPXUOU.js +3 -0
  30. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  31. package/dist/{chunk-ZORVNOKX.js → chunk-FMK7UQ6V.js} +24 -145
  32. package/dist/chunk-FMK7UQ6V.js.map +1 -0
  33. package/dist/{chunk-BVL7Y574.js → chunk-FZ7YFQN7.js} +26 -10
  34. package/dist/chunk-FZ7YFQN7.js.map +1 -0
  35. package/dist/{chunk-USO6TERP.js → chunk-GOZLNM4L.js} +11 -9
  36. package/dist/chunk-GOZLNM4L.js.map +1 -0
  37. package/dist/{chunk-H5VIV7MQ.js → chunk-I5DUQ5HM.js} +88 -82
  38. package/dist/chunk-I5DUQ5HM.js.map +1 -0
  39. package/dist/chunk-J7OWWTCW.js +143 -0
  40. package/dist/chunk-J7OWWTCW.js.map +1 -0
  41. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  42. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  43. package/dist/{chunk-VQ47KWD5.js → chunk-KMJ7RWD3.js} +18 -7
  44. package/dist/chunk-KMJ7RWD3.js.map +1 -0
  45. package/dist/{chunk-ERFMC6WU.js → chunk-M7HP3FTK.js} +20 -11
  46. package/dist/chunk-M7HP3FTK.js.map +1 -0
  47. package/dist/chunk-MIM5R4ZT.js +14972 -0
  48. package/dist/chunk-MIM5R4ZT.js.map +1 -0
  49. package/dist/{chunk-HFRE4QQV.js → chunk-T5IBSPAM.js} +96 -32
  50. package/dist/chunk-T5IBSPAM.js.map +1 -0
  51. package/dist/chunk-UNAZSLWE.js +294 -0
  52. package/dist/chunk-UNAZSLWE.js.map +1 -0
  53. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  54. package/dist/chunk-WPLAANH4.js.map +1 -0
  55. package/dist/{chunk-AWH3SRPW.js → chunk-Y23G2VZL.js} +5 -37
  56. package/dist/chunk-Y23G2VZL.js.map +1 -0
  57. package/dist/{chunk-MOLIUNVH.js → chunk-YT75VYRD.js} +4 -4
  58. package/dist/{chunk-MOLIUNVH.js.map → chunk-YT75VYRD.js.map} +1 -1
  59. package/dist/chunk-ZIFPHIWZ.js +302 -0
  60. package/dist/chunk-ZIFPHIWZ.js.map +1 -0
  61. package/dist/config/index.d.ts +48 -40
  62. package/dist/config/index.js +4 -5
  63. package/dist/controls/index.d.ts +91 -7
  64. package/dist/controls/index.js +5 -4
  65. package/dist/dragdrop/index.d.ts +9 -7
  66. package/dist/dragdrop/index.js +3 -2
  67. package/dist/index.d.ts +294 -82
  68. package/dist/index.js +425 -402
  69. package/dist/index.js.map +1 -1
  70. package/dist/layers/index.d.ts +4 -5
  71. package/dist/layers/index.js +2 -1
  72. package/dist/loupe/index.d.ts +20 -17
  73. package/dist/loupe/index.js +5 -3
  74. package/dist/overview/index.d.ts +56 -0
  75. package/dist/overview/index.js +289 -0
  76. package/dist/overview/index.js.map +1 -0
  77. package/dist/passthrough/weasel-ui.d.ts +239 -69
  78. package/dist/passthrough/weasel-ui.js +3 -2
  79. package/dist/primitives/index.d.ts +5 -204
  80. package/dist/primitives/index.js +6 -4
  81. package/dist/state/index.d.ts +8 -8
  82. package/dist/state/index.js +4 -5
  83. package/dist/state/index.js.map +1 -1
  84. package/dist/styles.css +152 -82
  85. package/dist/undo/index.d.ts +4 -4
  86. package/package.json +17 -12
  87. package/src/annotations/AnnotationOverlay.tsx +2 -2
  88. package/src/annotations/Annotations.less +0 -10
  89. package/src/annotations/MarkList.tsx +4 -8
  90. package/src/annotations/index.ts +1 -0
  91. package/src/annotations/paint.test.ts +3 -14
  92. package/src/annotations/paint.ts +3 -19
  93. package/src/annotations/store.test.ts +26 -0
  94. package/src/annotations/store.ts +25 -6
  95. package/src/annotations/svgNodes.test.ts +6 -9
  96. package/src/annotations/svgNodes.ts +3 -3
  97. package/src/annotations/toolMap.test.ts +37 -2
  98. package/src/annotations/toolMap.ts +13 -0
  99. package/src/annotations/types.ts +27 -0
  100. package/src/canvas/AGENTS.md +9 -10
  101. package/src/canvas/CameraInput.test.tsx +136 -0
  102. package/src/canvas/CameraInput.tsx +322 -0
  103. package/src/canvas/CanvasStack.tsx +47 -58
  104. package/src/canvas/LinkedCursor.less +16 -0
  105. package/src/canvas/LinkedCursor.tsx +54 -0
  106. package/src/canvas/Stage.test.tsx +33 -4
  107. package/src/canvas/Stage.tsx +47 -37
  108. package/src/canvas/cameraRegistry.test.ts +55 -0
  109. package/src/canvas/cameraRegistry.ts +86 -0
  110. package/src/canvas/cameraView.test.ts +60 -0
  111. package/src/canvas/cameraView.ts +61 -0
  112. package/src/canvas/cameraZoom.test.ts +46 -0
  113. package/src/canvas/cameraZoom.ts +24 -0
  114. package/src/canvas/index.ts +23 -2
  115. package/src/canvas/sharedScope.test.tsx +94 -0
  116. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  117. package/src/chrome/regions/PaletteRegion.less +0 -7
  118. package/src/chrome/regions/SidebarRegion.less +12 -2
  119. package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
  120. package/src/config/builder.test.ts +2 -1
  121. package/src/config/builder.ts +15 -8
  122. package/src/config/entry.test.ts +3 -1
  123. package/src/config/fromConfigField.test.ts +14 -0
  124. package/src/config/fromConfigField.ts +19 -4
  125. package/src/config/index.ts +4 -1
  126. package/src/config/path.ts +2 -5
  127. package/src/config/resolve.ts +15 -1
  128. package/src/config/sectionTree.test.ts +12 -6
  129. package/src/config/sectionTree.ts +15 -5
  130. package/src/config/types.ts +34 -9
  131. package/src/controls/ControlPanel.less +1 -1
  132. package/src/controls/ControlPanel.stories.tsx +3 -2
  133. package/src/controls/ControlPanel.test.tsx +76 -13
  134. package/src/controls/ControlPanel.tsx +80 -11
  135. package/src/controls/inDialog.test.tsx +18 -0
  136. package/src/controls/inDialog.tsx +2 -10
  137. package/src/index.test.ts +1 -1
  138. package/src/index.ts +23 -5
  139. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  140. package/src/instrument/types.ts +10 -1
  141. package/src/lab/Lab.nebula.test.tsx +11 -2
  142. package/src/lab/Lab.test.tsx +3 -1
  143. package/src/lab/Lab.tsx +87 -76
  144. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  145. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  146. package/src/lab/LabFullChrome.stories.tsx +1 -0
  147. package/src/lab/LabHeader.test.tsx +24 -14
  148. package/src/lab/LabHeader.tsx +11 -12
  149. package/src/lab/LabShell.less +8 -17
  150. package/src/lab/LabSwitcher.less +6 -6
  151. package/src/lab/LabZoom.test.tsx +223 -0
  152. package/src/lab/LabZoom.tsx +170 -0
  153. package/src/lab/index.ts +1 -0
  154. package/src/loupe/AGENTS.md +7 -6
  155. package/src/loupe/LoupeGestures.tsx +15 -10
  156. package/src/loupe/TrialLoupe.tsx +5 -5
  157. package/src/loupe/index.ts +1 -0
  158. package/src/loupe/loupeActions.ts +6 -2
  159. package/src/overview/AGENTS.md +38 -0
  160. package/src/overview/OverviewMarks.test.tsx +100 -0
  161. package/src/overview/OverviewMarks.tsx +93 -0
  162. package/src/overview/TrialOverview.less +53 -0
  163. package/src/overview/TrialOverview.stories.tsx +50 -0
  164. package/src/overview/TrialOverview.test.tsx +116 -0
  165. package/src/overview/TrialOverview.tsx +376 -0
  166. package/src/overview/index.ts +2 -0
  167. package/src/passthrough/weasel-ui.test.ts +2 -0
  168. package/src/passthrough/weasel-ui.ts +23 -4
  169. package/src/primitives/FloatingPanel.test.tsx +11 -0
  170. package/src/primitives/FloatingPanel.tsx +2 -5
  171. package/src/primitives/JobProgress.less +1 -1
  172. package/src/primitives/Legend.stories.tsx +1 -0
  173. package/src/primitives/Readout.browser.test.tsx +137 -0
  174. package/src/primitives/Readout.less +12 -0
  175. package/src/primitives/Readout.stories.tsx +58 -0
  176. package/src/primitives/Readout.test.tsx +93 -0
  177. package/src/primitives/Readout.tsx +56 -0
  178. package/src/primitives/index.ts +2 -0
  179. package/src/specimen/Specimen.stories.tsx +1 -0
  180. package/src/specimen/Specimen.tsx +21 -4
  181. package/src/state/Persistence.browser.test.tsx +47 -0
  182. package/src/state/labRecords.test.ts +4 -1
  183. package/src/state/store.test.ts +9 -5
  184. package/src/state/usePersistedState.test.tsx +1 -1
  185. package/src/state/useTrialState.test.tsx +1 -1
  186. package/src/styles.less +3 -0
  187. package/src/theme/Interstellar.stories.tsx +4 -6
  188. package/src/theme/base.less +21 -47
  189. package/src/theme/interstellar.test.ts +1 -1
  190. package/src/theme/interstellar.theme.json +10 -3
  191. package/src/tools/labTool.ts +6 -2
  192. package/src/trial/Trial.annotations.test.tsx +16 -0
  193. package/src/trial/Trial.less +0 -13
  194. package/src/trial/Trial.loupe.test.tsx +73 -1
  195. package/src/trial/Trial.stories.tsx +2 -0
  196. package/src/trial/Trial.tsx +23 -1
  197. package/src/trial/TrialChrome.tsx +21 -3
  198. package/src/trial/trialOps.test.ts +4 -4
  199. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  200. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
  201. package/dist/_dts/index-DINmCuLA.d.ts +0 -453
  202. package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
  203. package/dist/_dts/usePanZoom-Bq73pwFe.d.ts +0 -117
  204. package/dist/chunk-2AYGEN57.js +0 -32
  205. package/dist/chunk-2AYGEN57.js.map +0 -1
  206. package/dist/chunk-AWH3SRPW.js.map +0 -1
  207. package/dist/chunk-BVL7Y574.js.map +0 -1
  208. package/dist/chunk-ERFMC6WU.js.map +0 -1
  209. package/dist/chunk-H5VIV7MQ.js.map +0 -1
  210. package/dist/chunk-HFRE4QQV.js.map +0 -1
  211. package/dist/chunk-RS3HRQWU.js.map +0 -1
  212. package/dist/chunk-SBXNQW4G.js +0 -3
  213. package/dist/chunk-TT5C5S6R.js +0 -368
  214. package/dist/chunk-TT5C5S6R.js.map +0 -1
  215. package/dist/chunk-USO6TERP.js.map +0 -1
  216. package/dist/chunk-VQ47KWD5.js.map +0 -1
  217. package/dist/chunk-WUJXJC5O.js +0 -14568
  218. package/dist/chunk-WUJXJC5O.js.map +0 -1
  219. package/dist/chunk-ZORVNOKX.js.map +0 -1
  220. package/src/canvas/usePanZoom.test.ts +0 -244
  221. package/src/canvas/usePanZoom.ts +0 -147
  222. package/src/state/Persistence.stories.tsx +0 -75
@@ -1,6 +1,7 @@
1
1
  import { act, fireEvent, render, screen, within } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { TOOL_PREF_KINDS, type ToolPrefKind } from '@weasel-js/core';
4
+ import { compact } from '@weasel-js/quantity';
4
5
  import type { PrefLeaf } from '@weasel-js/ui';
5
6
  import { useState } from 'react';
6
7
  import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -88,7 +89,7 @@ describe('<ControlPanel> number', () => {
88
89
  ];
89
90
  render(<ControlPanel fields={fields} config={{ n: 42 }} setConfig={vi.fn()} />);
90
91
  const input = screen.getByLabelText('N') as HTMLInputElement;
91
- expect(input.type).toBe('number');
92
+ expect(input).toHaveAttribute('role', 'spinbutton');
92
93
  expect(input.value).toBe('42');
93
94
  });
94
95
 
@@ -143,7 +144,7 @@ describe('<ControlPanel> color', () => {
143
144
  const input = screen.getByLabelText('Color') as HTMLInputElement;
144
145
  expect(input.type).toBe('color');
145
146
  expect(input.value).toBe('#ff0000');
146
- fireEvent.change(input, { target: { value: '#00ff00' } });
147
+ fireEvent.input(input, { target: { value: '#00ff00' } });
147
148
  expect(setConfig).toHaveBeenCalledWith('c', '#00ff00');
148
149
  });
149
150
  });
@@ -206,7 +207,7 @@ describe('<ControlPanel> schema', () => {
206
207
  [],
207
208
  );
208
209
  render(<ControlPanel schema={schema} config={{ a: 5, b: 5 }} setConfig={vi.fn()} />);
209
- expect(screen.getByLabelText('B')).toHaveAttribute('type', 'number');
210
+ expect(screen.getByLabelText('B')).toHaveAttribute('role', 'spinbutton');
210
211
  });
211
212
 
212
213
  it('falls back to the leaf default when config holds no value', () => {
@@ -372,6 +373,7 @@ describe('<ControlPanel> built-in kind coverage', () => {
372
373
  sections: [],
373
374
  showIf: new Map(),
374
375
  renderers: {},
376
+ dialogs: {},
375
377
  });
376
378
 
377
379
  it('declines paint and object with a named row rather than a blank one', () => {
@@ -465,7 +467,7 @@ describe('<ControlPanel> suffix', () => {
465
467
 
466
468
  it('suffixes a typed number with the suffix the leaf declares', () => {
467
469
  const { container } = panel(f.number(20).input().suffix('px'));
468
- expect(container.querySelector('input[type="number"]')).not.toBeNull();
470
+ expect(container.querySelector('input[role="spinbutton"]')).not.toBeNull();
469
471
  expect(container.textContent).toContain('px');
470
472
  });
471
473
  });
@@ -475,7 +477,7 @@ describe('<ControlPanel> format', () => {
475
477
  render(
476
478
  <ControlPanel
477
479
  schema={resolveConfigSchema(
478
- f.schema({ glyphs: f.number(0).range(0, 2_000_000).format('compact') }),
480
+ f.schema({ glyphs: f.number(0).range(0, 2_000_000).display(compact()) }),
479
481
  [],
480
482
  )}
481
483
  config={{ glyphs: 2_000_000 }}
@@ -897,6 +899,28 @@ describe('<ControlPanel> auto', () => {
897
899
  });
898
900
  });
899
901
 
902
+ describe('<ControlPanel> option icons', () => {
903
+ it("draws an option's icon in its segment and keeps the label as its name", () => {
904
+ const status = f
905
+ .enum('ok', [
906
+ { value: 'ok', label: 'ok', icon: 'statusSuccess' },
907
+ { value: 'bad', label: 'bad' },
908
+ ])
909
+ .radio();
910
+ render(
911
+ <ControlPanel
912
+ schema={resolveConfigSchema(f.schema({ status }), [])}
913
+ config={{ status: 'ok' }}
914
+ setConfig={() => {}}
915
+ />,
916
+ );
917
+ const ok = screen.getByRole('radio', { name: 'ok' });
918
+ expect(ok.querySelector('svg')).not.toBeNull();
919
+ expect(ok.textContent).toBe('');
920
+ expect(screen.getByRole('radio', { name: 'bad' }).textContent).toBe('bad');
921
+ });
922
+ });
923
+
900
924
  describe('<ControlPanel> pair', () => {
901
925
  const paired = () =>
902
926
  resolveConfigSchema(
@@ -912,8 +936,8 @@ describe('<ControlPanel> pair', () => {
912
936
  it('draws two leaves sharing a pair id as one row named by the pair', () => {
913
937
  render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
914
938
  const row = screen.getByText('Offset').closest('div') as HTMLElement;
915
- expect(within(row).getByLabelText('X')).toHaveValue(10);
916
- expect(within(row).getByLabelText('Y')).toHaveValue(20);
939
+ expect(within(row).getByLabelText('X')).toHaveValue('10');
940
+ expect(within(row).getByLabelText('Y')).toHaveValue('20');
917
941
  });
918
942
 
919
943
  it('captions each cell of a pair with its own name, so a paired switch is not left unnamed', () => {
@@ -951,7 +975,7 @@ describe('<ControlPanel> pair', () => {
951
975
  render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
952
976
  const row = screen.getByText('Offset').closest('div') as HTMLElement;
953
977
  expect(within(row).queryByLabelText('Scale')).toBeNull();
954
- expect(screen.getByLabelText('Scale')).toHaveValue(1);
978
+ expect(screen.getByLabelText('Scale')).toHaveValue('1');
955
979
  });
956
980
  });
957
981
 
@@ -1044,18 +1068,18 @@ describe('<ControlPanel> color alpha', () => {
1044
1068
  it('splits the stored hex into a swatch and an alpha track', () => {
1045
1069
  panel();
1046
1070
  expect((screen.getByLabelText('Tint') as HTMLInputElement).value).toBe('#336699');
1047
- expect((screen.getByLabelText('Tint opacity') as HTMLInputElement).value).toBe('0.8');
1071
+ expect((screen.getByLabelText('Tint opacity') as HTMLInputElement).value).toBe('80');
1048
1072
  });
1049
1073
 
1050
1074
  it('keeps the stored alpha when the swatch moves', () => {
1051
1075
  const setConfig = panel();
1052
- fireEvent.change(screen.getByLabelText('Tint'), { target: { value: '#00ff00' } });
1076
+ fireEvent.input(screen.getByLabelText('Tint'), { target: { value: '#00ff00' } });
1053
1077
  expect(setConfig).toHaveBeenCalledWith('tint', '#00ff00cc');
1054
1078
  });
1055
1079
 
1056
1080
  it('keeps the stored color when the alpha track moves', () => {
1057
1081
  const setConfig = panel();
1058
- fireEvent.change(screen.getByLabelText('Tint opacity'), { target: { value: '0.5' } });
1082
+ fireEvent.input(screen.getByLabelText('Tint opacity'), { target: { value: '50' } });
1059
1083
  expect(setConfig).toHaveBeenCalledWith('tint', '#33669980');
1060
1084
  });
1061
1085
 
@@ -1075,14 +1099,23 @@ describe('<ControlPanel> title, stance and tone', () => {
1075
1099
  const fields: ConfigField[] = [{ key: 'on', label: 'On', type: 'checkbox', default: true }];
1076
1100
 
1077
1101
  it('renders the bare list when given none of them', () => {
1078
- const { container } = render(<ControlPanel fields={fields} config={{ on: true }} setConfig={vi.fn()} />);
1102
+ const { container } = render(
1103
+ <ControlPanel fields={fields} config={{ on: true }} setConfig={vi.fn()} />,
1104
+ );
1079
1105
  expect(container.firstElementChild?.classList.contains('lk-control-panel')).toBe(true);
1080
1106
  expect(container.querySelector('[data-stance], h2')).toBeNull();
1081
1107
  });
1082
1108
 
1083
1109
  it('wraps its rows in a panel carrying them when asked', () => {
1084
1110
  const { container } = render(
1085
- <ControlPanel title="View" stance="scope" tone={1} fields={fields} config={{ on: true }} setConfig={vi.fn()} />,
1111
+ <ControlPanel
1112
+ title="View"
1113
+ stance="scope"
1114
+ tone={1}
1115
+ fields={fields}
1116
+ config={{ on: true }}
1117
+ setConfig={vi.fn()}
1118
+ />,
1086
1119
  );
1087
1120
  const panel = container.firstElementChild as HTMLElement;
1088
1121
  expect(panel.dataset.stance).toBe('scope');
@@ -1091,3 +1124,33 @@ describe('<ControlPanel> title, stance and tone', () => {
1091
1124
  expect(panel.querySelector('.lk-control-panel')).not.toBeNull();
1092
1125
  });
1093
1126
  });
1127
+
1128
+ describe('<ControlPanel> resettable group', () => {
1129
+ const schema = resolveConfigSchema(
1130
+ f.schema({
1131
+ name: f.string('a'),
1132
+ modes: f.group({ one: f.number(0), two: f.number(0), inner: f.group({ three: f.number(1) }) }).resettable(),
1133
+ plain: f.group({ four: f.number(0) }),
1134
+ }),
1135
+ );
1136
+
1137
+ it('puts a reset button on the group heading, disabled while nothing has changed', () => {
1138
+ render(
1139
+ <ControlPanel schema={schema} config={{ name: 'a', modes: { one: 0, two: 0, inner: { three: 1 } }, plain: { four: 0 } }} setConfig={vi.fn()} />,
1140
+ );
1141
+ expect(screen.getByRole('button', { name: 'Reset Modes' })).toBeDisabled();
1142
+ expect(screen.queryByRole('button', { name: 'Reset Plain' })).toBeNull();
1143
+ });
1144
+
1145
+ it('writes the default of every changed value beneath the group and nothing else', () => {
1146
+ const setConfig = vi.fn();
1147
+ render(
1148
+ <ControlPanel schema={schema} config={{ name: 'b', modes: { one: 2, two: 0, inner: { three: 5 } }, plain: { four: 3 } }} setConfig={setConfig} />,
1149
+ );
1150
+ fireEvent.click(screen.getByRole('button', { name: 'Reset Modes' }));
1151
+ expect(setConfig.mock.calls).toEqual([
1152
+ ['modes.one', 0],
1153
+ ['modes.inner.three', 1],
1154
+ ]);
1155
+ });
1156
+ });
@@ -1,13 +1,14 @@
1
1
  import { getAlpha01, isBuiltinToolPref, toHex8, withAlpha01 } from '@weasel-js/core';
2
2
  import {
3
+ Button,
3
4
  DialogRow,
4
5
  isPrefLeaf,
5
6
  ListEditor,
6
7
  type PrefGroup,
7
8
  type PrefLeaf,
8
9
  type PropertyAlign,
9
- type PropertyControlProps,
10
10
  PropertyControl,
11
+ type PropertyControlProps,
11
12
  type PropertyDensity,
12
13
  PropertyField,
13
14
  PropertyGroup,
@@ -17,6 +18,7 @@ import {
17
18
  PropertyRow,
18
19
  type PropertyRowLayout,
19
20
  prefFieldProps,
21
+ ResetIcon,
20
22
  type StanceProps,
21
23
  } from '@weasel-js/ui';
22
24
  import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
@@ -26,7 +28,7 @@ import { fromConfigFields } from '../config/fromConfigField';
26
28
  import { schemaNodeAtPath, valueAtPath } from '../config/path';
27
29
  import type { ControlRenderer, ResolvedConfig, SectionSpec } from '../config/types';
28
30
  import { isLeafVisible } from '../config/visible';
29
- import { summarizeValue } from './inDialog';
31
+ import { inDialog, summarizeValue } from './inDialog';
30
32
  import type { ConfigField } from './types';
31
33
 
32
34
  /** How a panel packs its rows into the two-column property grid.
@@ -117,6 +119,45 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> extends S
117
119
 
118
120
  const NO_AUTO: ReadonlySet<string> = new Set();
119
121
 
122
+ /** Every leaf beneath a group, as its dotted path and its default. */
123
+ function leavesUnder(group: PrefGroup, at: string): [string, unknown][] {
124
+ return Object.entries(group.children).flatMap(([key, child]) => {
125
+ const path = at === '' ? key : `${at}.${key}`;
126
+ return isPrefLeaf(child) ? [[path, child.default] as [string, unknown]] : leavesUnder(child, path);
127
+ });
128
+ }
129
+
130
+ /** The heading button of a `.resettable()` group: writes each changed leaf's
131
+ * default, and is disabled while nothing has changed. */
132
+ function ResetGroup({
133
+ group,
134
+ path,
135
+ config,
136
+ setConfig,
137
+ }: {
138
+ group: PrefGroup;
139
+ path: string;
140
+ config: Record<string, unknown>;
141
+ setConfig: (path: string, value: unknown) => void;
142
+ }) {
143
+ const changed = leavesUnder(group, path).filter(([p, d]) => !Object.is(valueAtPath(config, p), d));
144
+ return (
145
+ <Button
146
+ iconOnly
147
+ variant="ghost"
148
+ size="sm"
149
+ ariaLabel={`Reset ${group.name}`}
150
+ tooltip={`Reset ${group.name} to defaults`}
151
+ disabled={changed.length === 0}
152
+ onClick={() => {
153
+ for (const [p, d] of changed) setConfig(p, d);
154
+ }}
155
+ >
156
+ <ResetIcon />
157
+ </Button>
158
+ );
159
+ }
160
+
120
161
  /** Render an instrument's config schema as a stack of controls, each writing
121
162
  * back through `setConfig`. Built on the property rows, so a lab's controls
122
163
  * are the same aligned, themed rows the rest of the kit uses. */
@@ -224,6 +265,11 @@ export function ControlPanel<TC extends Record<string, unknown>>({
224
265
  // two-column grid leaves it laying its own rows out inside a cell,
225
266
  // which overlaps them.
226
267
  span
268
+ actions={
269
+ resolved.resettable?.has(path) ? (
270
+ <ResetGroup group={found} path={path} config={config as Record<string, unknown>} setConfig={setConfig} />
271
+ ) : undefined
272
+ }
227
273
  {...fold(path, undefined, rows.grid)}
228
274
  >
229
275
  {body(found, path, rows)}
@@ -243,7 +289,13 @@ export function ControlPanel<TC extends Record<string, unknown>>({
243
289
  if (pair === undefined) return undefined;
244
290
  if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden))
245
291
  return undefined;
246
- if (renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[found.kind]) return undefined;
292
+ if (
293
+ renderers?.[path] ??
294
+ resolved.renderers[path] ??
295
+ resolved.dialogs[path] ??
296
+ renderers?.[found.kind]
297
+ )
298
+ return undefined;
247
299
  if (!isBuiltinToolPref(found)) return undefined;
248
300
  if (found.kind === 'paint' || found.kind === 'object' || isSliderLeaf(found)) return undefined;
249
301
  return { leaf: found, pair };
@@ -405,8 +457,13 @@ function ControlRow<TC extends Record<string, unknown>>({
405
457
  const autoProps = { auto: isAutoRow, onAutoChange };
406
458
 
407
459
  // 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];
460
+ // instrument's: controls[path] -> node .render/.dialog -> controls[kind] -> built-in.
461
+ const dialog = resolved.dialogs[path];
462
+ const custom =
463
+ renderers?.[path] ??
464
+ resolved.renderers[path] ??
465
+ (dialog ? inDialog(dialog.body, dialog) : undefined) ??
466
+ renderers?.[leaf.kind];
410
467
  // A custom row places itself like any other; one that needs the full width
411
468
  // says so with `<PropertyRow span>`, which is the same opt-out a built-in has.
412
469
  if (custom) return custom({ path, pref: leaf, value, setValue: write, auto: isAutoRow, setAuto });
@@ -442,14 +499,15 @@ function ControlRow<TC extends Record<string, unknown>>({
442
499
  }
443
500
 
444
501
  const field = labField(leaf, value, write);
445
- if (field === null) return <UnwiredRow label={label} kind={leaf.kind} description={description} />;
502
+ if (field === null)
503
+ return <UnwiredRow label={label} kind={leaf.kind} description={description} />;
446
504
  switch (field.kind) {
447
505
  case 'string':
448
506
  return <DebouncedTextRow leaf={leaf} field={field} span={wide} {...row} />;
449
507
  case 'number':
450
508
  return <PropertyField {...field} {...row} span={field.control === 'slider' && wide} />;
451
509
  case 'enum':
452
- return <PropertyField {...field} {...row} span={field.control === 'radio' && wide} />;
510
+ return <PropertyField {...field} {...row} span={field.control === 'toggle' && wide} />;
453
511
  default:
454
512
  return <PropertyField {...field} {...row} />;
455
513
  }
@@ -479,9 +537,16 @@ function labField(
479
537
  unit: extra<string>(leaf, 'suffix') ?? field.unit,
480
538
  };
481
539
  case 'boolean':
482
- return { ...field, control: extra<string>(leaf, 'control') === 'switch' ? 'switch' : 'checkbox' };
540
+ return {
541
+ ...field,
542
+ control: extra<string>(leaf, 'control') === 'switch' ? 'switch' : 'checkbox',
543
+ };
483
544
  case 'enum':
484
- return { ...field, control: extra<string>(leaf, 'control') === 'radio' ? 'radio' : 'select' };
545
+ // `.radio()` asks for every option at once, as segments.
546
+ return {
547
+ ...field,
548
+ control: extra<string>(leaf, 'control') === 'radio' ? 'toggle' : 'select',
549
+ };
485
550
  case 'string':
486
551
  return {
487
552
  ...field,
@@ -605,7 +670,9 @@ function PairCell({
605
670
  if (field === null) return null;
606
671
  switch (field.kind) {
607
672
  case 'string':
608
- return <PropertyControl {...field} name={leaf.name} value={text.local} onChange={text.type} />;
673
+ return (
674
+ <PropertyControl {...field} name={leaf.name} value={text.local} onChange={text.type} />
675
+ );
609
676
  case 'number':
610
677
  // The pair names the row, so a cell has no room for a unit.
611
678
  return <PropertyControl {...field} name={leaf.name} control="input" unit={undefined} />;
@@ -618,7 +685,9 @@ function PairCell({
618
685
  {...field}
619
686
  name={leaf.name}
620
687
  alpha={undefined}
621
- onChange={(rgb: string) => write(field.alpha ? withAlpha01(rgb, getAlpha01(stored)) : rgb)}
688
+ onChange={(rgb: string) =>
689
+ write(field.alpha ? withAlpha01(rgb, getAlpha01(stored)) : rgb)
690
+ }
622
691
  />
623
692
  );
624
693
  }
@@ -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
@@ -1,4 +1,4 @@
1
- // The property-panel family, LayerList, Input, Select, TokenPanel, ToggleBar and Button live in
1
+ // The property-panel family, DetailList, LayerList, Input, Select, TokenPanel, ToggleBar and Button live in
2
2
  // `@weasel-js/ui`; labkit re-exports them so chrome built on labkit needs no
3
3
  // direct ui dependency. Named, not `export *` — a star re-export of an external
4
4
  // package emits no binding in the bundle.
@@ -10,6 +10,11 @@ export type {
10
10
  ButtonVariant,
11
11
  CurveFieldProps,
12
12
  CurveMark,
13
+ DetailListLayout,
14
+ DetailListProps,
15
+ DetailListValues,
16
+ DetailRowProps,
17
+ DetailRowStatus,
13
18
  IconProps,
14
19
  InputProps,
15
20
  JogProps,
@@ -41,6 +46,8 @@ export {
41
46
  CloseIcon,
42
47
  CollapseIcon,
43
48
  CurveField,
49
+ DetailList,
50
+ DetailRow,
44
51
  ExpandIcon,
45
52
  Icon,
46
53
  Input,
@@ -79,6 +86,7 @@ export type {
79
86
  AnnotationsCapability,
80
87
  AnnotationTarget,
81
88
  AnnotationTargetInfo,
89
+ AnnotationToolId,
82
90
  CaptureDeps,
83
91
  CaptureOptions,
84
92
  CaptureResult,
@@ -129,29 +137,37 @@ export {
129
137
  export type { ContentSize, PaneSize } from './annotations/view';
130
138
  export { fitView, fromWeaselView, toWeaselView } from './annotations/view';
131
139
  export type {
140
+ CameraContextValue,
141
+ CameraRegistry,
142
+ CameraView,
132
143
  CanvasLayerDescriptor,
133
144
  CanvasStackContextValue,
134
145
  CanvasStackProps,
135
146
  CanvasStackSurface,
136
- PanZoomHandlers,
137
147
  StageProps,
138
- UsePanZoomOptions,
139
148
  ViewportSize,
140
149
  WorldFrame,
141
150
  WorldSpec,
142
151
  ZoomAtOptions,
152
+ ZoomRange,
143
153
  } from './canvas';
144
154
  export {
145
155
  applyCamera,
156
+ CameraContext,
157
+ CameraInput,
158
+ CameraPublishContext,
159
+ CameraRegistryContext,
160
+ CameraScope,
146
161
  CanvasStack,
147
162
  CanvasStackContext,
148
163
  centerOn,
164
+ createCameraRegistry,
149
165
  DEFAULT_FRAME,
150
166
  fitStage,
151
167
  resolveFrame,
152
168
  Stage,
153
169
  screenToWorld,
154
- usePanZoom,
170
+ usePublishCamera,
155
171
  worldToScreen,
156
172
  zoomAt,
157
173
  } from './canvas';
@@ -173,7 +189,6 @@ export {
173
189
  ValueNode,
174
190
  } from './config/builder';
175
191
  export { fromConfigFields } from './config/fromConfigField';
176
- export { type SectionTree, sectionTree } from './config/sectionTree';
177
192
  export {
178
193
  fillConfigDefaults,
179
194
  hasConfigPath,
@@ -183,6 +198,7 @@ export {
183
198
  } from './config/path';
184
199
  export { resolveConfigSchema } from './config/resolve';
185
200
  export { applyRules, builtinRules, titleCase } from './config/rules';
201
+ export { type SectionTree, sectionTree } from './config/sectionTree';
186
202
  export type {
187
203
  Annotations,
188
204
  BranchAnnotations,
@@ -198,12 +214,14 @@ export type {
198
214
  ConfigSchema,
199
215
  ConfigShape,
200
216
  ControlRenderer,
217
+ DialogSpec,
201
218
  EntryValue,
202
219
  InferConfig,
203
220
  LeafPatch,
204
221
  NodeOptions,
205
222
  NodeValue,
206
223
  ResolvedConfig,
224
+ SectionOption,
207
225
  SectionSpec,
208
226
  ValueAtPath,
209
227
  } 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;
@@ -19,12 +19,21 @@ const backdrop = (container: HTMLElement) =>
19
19
 
20
20
  describe('<Lab nebula>', () => {
21
21
  it('draws one blob per literal color', () => {
22
- const { container } = render(<Lab instruments={[bare]} defaultInstrument="Bare" mode="dark" nebula={['#ff0000', '#00ff00']} />);
22
+ const { container } = render(
23
+ <Lab
24
+ instruments={[bare]}
25
+ defaultInstrument="Bare"
26
+ mode="dark"
27
+ nebula={['#ff0000', '#00ff00']}
28
+ />,
29
+ );
23
30
  expect(backdrop(container).match(/radial-gradient/g)).toHaveLength(3);
24
31
  });
25
32
 
26
33
  it('resolves a theme ramp against the lab’s theme', () => {
27
- const { container } = render(<Lab instruments={[bare]} defaultInstrument="Bare" mode="dark" nebula={{ ramp: 'swatch' }} />);
34
+ const { container } = render(
35
+ <Lab instruments={[bare]} defaultInstrument="Bare" mode="dark" nebula={{ ramp: 'swatch' }} />,
36
+ );
28
37
  const sky = resolveTheme(interstellarTheme, { mode: 'dark' })['--wzl-swatch-sky'];
29
38
  expect(backdrop(container)).toContain(sky);
30
39
  });
@@ -165,7 +165,9 @@ describe('<Lab>', () => {
165
165
  storageKey: 's',
166
166
  });
167
167
  await waitFor(() => expect(labRef?.trials[0]).toBeDefined());
168
- expect((labRef?.trials[0]?.state as { seen: Set<string> }).seen).toEqual(new Set(['a']));
168
+ expect((labRef?.trials[0]?.state as { seen: Set<string> } | undefined)?.seen).toEqual(
169
+ new Set(['a']),
170
+ );
169
171
  });
170
172
  });
171
173