@weasel-js/labkit 1.6.1 → 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 (162) hide show
  1. package/dist/_dts/{CanvasStackContext-Wo6OwIVM.d.ts → CanvasStackContext-D2jnIwfk.d.ts} +1 -1
  2. package/dist/_dts/{LayerList.d-BoELAMsM.d.ts → LayerList.d-DiuEArKq.d.ts} +2 -2
  3. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +79 -0
  4. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +141 -0
  5. package/dist/_dts/{Select.d-Cr8-zoup.d.ts → Select.d-Bensr_MT.d.ts} +11 -1
  6. package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-CCWfQQWM.d.ts} +58 -18
  7. package/dist/_dts/index-B2Yj5aA0.d.ts +274 -0
  8. package/dist/_dts/{index-CKyX0ME_.d.ts → index-CsYqUHD3.d.ts} +2 -2
  9. package/dist/_dts/{types-DTuViBvc.d.ts → types-CRPSGFAp.d.ts} +13 -4
  10. package/dist/_dts/{types-B7yBTTF9.d.ts → types-CSm9jgol.d.ts} +2 -2
  11. package/dist/_dts/{useTrialState-CMaV6UD4.d.ts → useTrialState-CTZPgeZ7.d.ts} +1 -1
  12. package/dist/canvas/index.d.ts +6 -6
  13. package/dist/canvas/index.js +2 -2
  14. package/dist/chrome/index.d.ts +5 -5
  15. package/dist/chrome/index.js +8 -8
  16. package/dist/{chunk-QGMOA2YE.js → chunk-275JRRVD.js} +67 -70
  17. package/dist/chunk-275JRRVD.js.map +1 -0
  18. package/dist/{chunk-KHK5FGER.js → chunk-4LJSC7MB.js} +8 -10
  19. package/dist/chunk-4LJSC7MB.js.map +1 -0
  20. package/dist/{chunk-SLAQPUI4.js → chunk-7BO7HNGF.js} +5 -5
  21. package/dist/{chunk-SLAQPUI4.js.map → chunk-7BO7HNGF.js.map} +1 -1
  22. package/dist/chunk-CZZPXUOU.js +3 -0
  23. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  24. package/dist/{chunk-6FLXL3I7.js → chunk-FMK7UQ6V.js} +19 -4
  25. package/dist/chunk-FMK7UQ6V.js.map +1 -0
  26. package/dist/{chunk-O6FFFOHS.js → chunk-FZ7YFQN7.js} +16 -8
  27. package/dist/chunk-FZ7YFQN7.js.map +1 -0
  28. package/dist/{chunk-GHGO52SO.js → chunk-GOZLNM4L.js} +5 -5
  29. package/dist/{chunk-GHGO52SO.js.map → chunk-GOZLNM4L.js.map} +1 -1
  30. package/dist/{chunk-4TT3PNPR.js → chunk-I5DUQ5HM.js} +33 -36
  31. package/dist/chunk-I5DUQ5HM.js.map +1 -0
  32. package/dist/{chunk-NEXR37YJ.js → chunk-J7OWWTCW.js} +4 -5
  33. package/dist/chunk-J7OWWTCW.js.map +1 -0
  34. package/dist/{chunk-IRTGUQ5O.js → chunk-KMJ7RWD3.js} +16 -6
  35. package/dist/chunk-KMJ7RWD3.js.map +1 -0
  36. package/dist/{chunk-S4NCI32V.js → chunk-M7HP3FTK.js} +4 -4
  37. package/dist/{chunk-S4NCI32V.js.map → chunk-M7HP3FTK.js.map} +1 -1
  38. package/dist/chunk-MIM5R4ZT.js +14972 -0
  39. package/dist/chunk-MIM5R4ZT.js.map +1 -0
  40. package/dist/{chunk-ASOK76ZH.js → chunk-T5IBSPAM.js} +59 -26
  41. package/dist/chunk-T5IBSPAM.js.map +1 -0
  42. package/dist/{chunk-DWMSB4CA.js → chunk-UNAZSLWE.js} +82 -16
  43. package/dist/chunk-UNAZSLWE.js.map +1 -0
  44. package/dist/{chunk-TJ77A4A3.js → chunk-Y23G2VZL.js} +5 -9
  45. package/dist/chunk-Y23G2VZL.js.map +1 -0
  46. package/dist/{chunk-HKV7LRHY.js → chunk-YT75VYRD.js} +4 -4
  47. package/dist/{chunk-HKV7LRHY.js.map → chunk-YT75VYRD.js.map} +1 -1
  48. package/dist/{chunk-CGHF6RZV.js → chunk-ZIFPHIWZ.js} +9 -7
  49. package/dist/chunk-ZIFPHIWZ.js.map +1 -0
  50. package/dist/config/index.d.ts +16 -9
  51. package/dist/config/index.js +4 -4
  52. package/dist/controls/index.d.ts +2 -2
  53. package/dist/controls/index.js +5 -5
  54. package/dist/dragdrop/index.d.ts +3 -3
  55. package/dist/index.d.ts +263 -24
  56. package/dist/index.js +376 -237
  57. package/dist/index.js.map +1 -1
  58. package/dist/layers/index.d.ts +4 -4
  59. package/dist/layers/index.js +2 -2
  60. package/dist/loupe/index.d.ts +4 -4
  61. package/dist/loupe/index.js +2 -2
  62. package/dist/overview/index.d.ts +2 -2
  63. package/dist/overview/index.js +9 -8
  64. package/dist/overview/index.js.map +1 -1
  65. package/dist/passthrough/weasel-ui.d.ts +208 -38
  66. package/dist/passthrough/weasel-ui.js +3 -3
  67. package/dist/primitives/index.d.ts +3 -178
  68. package/dist/primitives/index.js +6 -6
  69. package/dist/state/index.d.ts +5 -5
  70. package/dist/state/index.js +4 -4
  71. package/dist/styles.css +91 -83
  72. package/dist/undo/index.d.ts +3 -3
  73. package/package.json +13 -12
  74. package/src/annotations/AnnotationOverlay.tsx +2 -2
  75. package/src/annotations/Annotations.less +0 -10
  76. package/src/annotations/MarkList.tsx +4 -8
  77. package/src/annotations/paint.test.ts +3 -14
  78. package/src/annotations/paint.ts +3 -19
  79. package/src/annotations/svgNodes.test.ts +6 -9
  80. package/src/annotations/svgNodes.ts +3 -3
  81. package/src/canvas/AGENTS.md +1 -0
  82. package/src/canvas/CameraInput.tsx +44 -33
  83. package/src/canvas/CanvasStack.tsx +6 -1
  84. package/src/canvas/LinkedCursor.less +1 -1
  85. package/src/canvas/Stage.test.tsx +33 -4
  86. package/src/canvas/Stage.tsx +6 -1
  87. package/src/canvas/cameraRegistry.test.ts +55 -0
  88. package/src/canvas/cameraRegistry.ts +86 -0
  89. package/src/canvas/cameraZoom.test.ts +46 -0
  90. package/src/canvas/cameraZoom.ts +24 -0
  91. package/src/canvas/index.ts +14 -2
  92. package/src/canvas/sharedScope.test.tsx +94 -0
  93. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  94. package/src/chrome/regions/PaletteRegion.less +0 -7
  95. package/src/chrome/regions/SidebarRegion.less +12 -2
  96. package/src/config/builder.test.ts +2 -1
  97. package/src/config/builder.ts +12 -5
  98. package/src/config/fromConfigField.test.ts +14 -0
  99. package/src/config/fromConfigField.ts +18 -4
  100. package/src/config/path.ts +2 -5
  101. package/src/config/resolve.ts +4 -0
  102. package/src/config/types.ts +13 -9
  103. package/src/controls/ControlPanel.less +1 -1
  104. package/src/controls/ControlPanel.stories.tsx +3 -2
  105. package/src/controls/ControlPanel.test.tsx +64 -11
  106. package/src/controls/ControlPanel.tsx +52 -2
  107. package/src/index.ts +15 -1
  108. package/src/lab/Lab.tsx +83 -72
  109. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  110. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  111. package/src/lab/LabFullChrome.stories.tsx +1 -0
  112. package/src/lab/LabHeader.test.tsx +24 -14
  113. package/src/lab/LabHeader.tsx +11 -12
  114. package/src/lab/LabShell.less +8 -17
  115. package/src/lab/LabSwitcher.less +6 -6
  116. package/src/lab/LabZoom.test.tsx +223 -0
  117. package/src/lab/LabZoom.tsx +170 -0
  118. package/src/lab/index.ts +1 -0
  119. package/src/loupe/LoupeGestures.tsx +1 -1
  120. package/src/overview/TrialOverview.tsx +7 -5
  121. package/src/passthrough/weasel-ui.test.ts +2 -0
  122. package/src/passthrough/weasel-ui.ts +16 -4
  123. package/src/primitives/FloatingPanel.test.tsx +11 -0
  124. package/src/primitives/FloatingPanel.tsx +2 -5
  125. package/src/primitives/JobProgress.less +1 -1
  126. package/src/primitives/Legend.stories.tsx +1 -0
  127. package/src/primitives/Readout.browser.test.tsx +137 -0
  128. package/src/primitives/Readout.less +12 -0
  129. package/src/primitives/Readout.stories.tsx +58 -0
  130. package/src/primitives/Readout.test.tsx +93 -0
  131. package/src/primitives/Readout.tsx +56 -0
  132. package/src/primitives/index.ts +2 -0
  133. package/src/specimen/Specimen.stories.tsx +1 -0
  134. package/src/specimen/Specimen.tsx +2 -2
  135. package/src/state/Persistence.browser.test.tsx +47 -0
  136. package/src/state/store.test.ts +5 -1
  137. package/src/state/usePersistedState.test.tsx +1 -1
  138. package/src/styles.less +1 -0
  139. package/src/theme/Interstellar.stories.tsx +3 -1
  140. package/src/theme/base.less +21 -47
  141. package/src/theme/interstellar.test.ts +1 -1
  142. package/src/theme/interstellar.theme.json +10 -3
  143. package/src/trial/Trial.less +0 -13
  144. package/src/trial/Trial.tsx +12 -6
  145. package/src/trial/TrialChrome.tsx +21 -3
  146. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  147. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
  148. package/dist/chunk-4TT3PNPR.js.map +0 -1
  149. package/dist/chunk-6FLXL3I7.js.map +0 -1
  150. package/dist/chunk-ASOK76ZH.js.map +0 -1
  151. package/dist/chunk-CGHF6RZV.js.map +0 -1
  152. package/dist/chunk-DWMSB4CA.js.map +0 -1
  153. package/dist/chunk-IRTGUQ5O.js.map +0 -1
  154. package/dist/chunk-KHK5FGER.js.map +0 -1
  155. package/dist/chunk-NEXR37YJ.js.map +0 -1
  156. package/dist/chunk-O6FFFOHS.js.map +0 -1
  157. package/dist/chunk-QGMOA2YE.js.map +0 -1
  158. package/dist/chunk-SBXNQW4G.js +0 -3
  159. package/dist/chunk-TJ77A4A3.js.map +0 -1
  160. package/dist/chunk-TYN6MLXX.js +0 -13864
  161. package/dist/chunk-TYN6MLXX.js.map +0 -1
  162. 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', () => {
@@ -466,7 +467,7 @@ describe('<ControlPanel> suffix', () => {
466
467
 
467
468
  it('suffixes a typed number with the suffix the leaf declares', () => {
468
469
  const { container } = panel(f.number(20).input().suffix('px'));
469
- expect(container.querySelector('input[type="number"]')).not.toBeNull();
470
+ expect(container.querySelector('input[role="spinbutton"]')).not.toBeNull();
470
471
  expect(container.textContent).toContain('px');
471
472
  });
472
473
  });
@@ -476,7 +477,7 @@ describe('<ControlPanel> format', () => {
476
477
  render(
477
478
  <ControlPanel
478
479
  schema={resolveConfigSchema(
479
- 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()) }),
480
481
  [],
481
482
  )}
482
483
  config={{ glyphs: 2_000_000 }}
@@ -898,6 +899,28 @@ describe('<ControlPanel> auto', () => {
898
899
  });
899
900
  });
900
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
+
901
924
  describe('<ControlPanel> pair', () => {
902
925
  const paired = () =>
903
926
  resolveConfigSchema(
@@ -913,8 +936,8 @@ describe('<ControlPanel> pair', () => {
913
936
  it('draws two leaves sharing a pair id as one row named by the pair', () => {
914
937
  render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
915
938
  const row = screen.getByText('Offset').closest('div') as HTMLElement;
916
- expect(within(row).getByLabelText('X')).toHaveValue(10);
917
- expect(within(row).getByLabelText('Y')).toHaveValue(20);
939
+ expect(within(row).getByLabelText('X')).toHaveValue('10');
940
+ expect(within(row).getByLabelText('Y')).toHaveValue('20');
918
941
  });
919
942
 
920
943
  it('captions each cell of a pair with its own name, so a paired switch is not left unnamed', () => {
@@ -952,7 +975,7 @@ describe('<ControlPanel> pair', () => {
952
975
  render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
953
976
  const row = screen.getByText('Offset').closest('div') as HTMLElement;
954
977
  expect(within(row).queryByLabelText('Scale')).toBeNull();
955
- expect(screen.getByLabelText('Scale')).toHaveValue(1);
978
+ expect(screen.getByLabelText('Scale')).toHaveValue('1');
956
979
  });
957
980
  });
958
981
 
@@ -1045,18 +1068,18 @@ describe('<ControlPanel> color alpha', () => {
1045
1068
  it('splits the stored hex into a swatch and an alpha track', () => {
1046
1069
  panel();
1047
1070
  expect((screen.getByLabelText('Tint') as HTMLInputElement).value).toBe('#336699');
1048
- expect((screen.getByLabelText('Tint opacity') as HTMLInputElement).value).toBe('0.8');
1071
+ expect((screen.getByLabelText('Tint opacity') as HTMLInputElement).value).toBe('80');
1049
1072
  });
1050
1073
 
1051
1074
  it('keeps the stored alpha when the swatch moves', () => {
1052
1075
  const setConfig = panel();
1053
- fireEvent.change(screen.getByLabelText('Tint'), { target: { value: '#00ff00' } });
1076
+ fireEvent.input(screen.getByLabelText('Tint'), { target: { value: '#00ff00' } });
1054
1077
  expect(setConfig).toHaveBeenCalledWith('tint', '#00ff00cc');
1055
1078
  });
1056
1079
 
1057
1080
  it('keeps the stored color when the alpha track moves', () => {
1058
1081
  const setConfig = panel();
1059
- fireEvent.change(screen.getByLabelText('Tint opacity'), { target: { value: '0.5' } });
1082
+ fireEvent.input(screen.getByLabelText('Tint opacity'), { target: { value: '50' } });
1060
1083
  expect(setConfig).toHaveBeenCalledWith('tint', '#33669980');
1061
1084
  });
1062
1085
 
@@ -1101,3 +1124,33 @@ describe('<ControlPanel> title, stance and tone', () => {
1101
1124
  expect(panel.querySelector('.lk-control-panel')).not.toBeNull();
1102
1125
  });
1103
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,5 +1,6 @@
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,
@@ -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';
@@ -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)}
@@ -461,7 +507,7 @@ function ControlRow<TC extends Record<string, unknown>>({
461
507
  case 'number':
462
508
  return <PropertyField {...field} {...row} span={field.control === 'slider' && wide} />;
463
509
  case 'enum':
464
- return <PropertyField {...field} {...row} span={field.control === 'radio' && wide} />;
510
+ return <PropertyField {...field} {...row} span={field.control === 'toggle' && wide} />;
465
511
  default:
466
512
  return <PropertyField {...field} {...row} />;
467
513
  }
@@ -496,7 +542,11 @@ function labField(
496
542
  control: extra<string>(leaf, 'control') === 'switch' ? 'switch' : 'checkbox',
497
543
  };
498
544
  case 'enum':
499
- 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
+ };
500
550
  case 'string':
501
551
  return {
502
552
  ...field,
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,
@@ -131,6 +138,8 @@ export type { ContentSize, PaneSize } from './annotations/view';
131
138
  export { fitView, fromWeaselView, toWeaselView } from './annotations/view';
132
139
  export type {
133
140
  CameraContextValue,
141
+ CameraRegistry,
142
+ CameraView,
134
143
  CanvasLayerDescriptor,
135
144
  CanvasStackContextValue,
136
145
  CanvasStackProps,
@@ -140,20 +149,25 @@ export type {
140
149
  WorldFrame,
141
150
  WorldSpec,
142
151
  ZoomAtOptions,
152
+ ZoomRange,
143
153
  } from './canvas';
144
154
  export {
145
155
  applyCamera,
146
156
  CameraContext,
147
157
  CameraInput,
158
+ CameraPublishContext,
159
+ CameraRegistryContext,
148
160
  CameraScope,
149
161
  CanvasStack,
150
162
  CanvasStackContext,
151
163
  centerOn,
164
+ createCameraRegistry,
152
165
  DEFAULT_FRAME,
153
166
  fitStage,
154
167
  resolveFrame,
155
168
  Stage,
156
169
  screenToWorld,
170
+ usePublishCamera,
157
171
  worldToScreen,
158
172
  zoomAt,
159
173
  } from './canvas';
package/src/lab/Lab.tsx CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  import { useStore } from 'zustand/react';
14
14
  import { AnnotationPreloadContext } from '../annotations/preload';
15
15
  import { labAnnotationTools } from '../annotations/toolMap';
16
+ import { CameraRegistryContext, createCameraRegistry } from '../canvas/cameraRegistry';
16
17
  import {
17
18
  LabAsideRegion,
18
19
  LabFooterRegion,
@@ -50,10 +51,11 @@ import {
50
51
  } from '../trial/trialOps';
51
52
  import { useLabFitWarning } from './fitCheck';
52
53
  import { LabContext, type LabContextValue } from './LabContext';
53
- import { LabHeader } from './LabHeader';
54
+ import { LabHeader, LabThemeSwitcher } from './LabHeader';
54
55
  import { LabPalette } from './LabPalette';
55
56
  import { LabShell } from './LabShell';
56
57
  import type { LabPage } from './LabSwitcher';
58
+ import { LabZoom } from './LabZoom';
57
59
  import { createPanelHostRegistry, PanelHostContext } from './panelHost';
58
60
  import { type PanelDescriptor, type TrialLayout, Workspace } from './Workspace';
59
61
 
@@ -104,6 +106,9 @@ interface LabBaseProps {
104
106
  /** Offer the header's add-trial control. Default `true`; a lab that opens
105
107
  * trials some other way passes `false`. */
106
108
  addTrial?: boolean;
109
+ /** Offer the header's zoom controls, which act on the focused trial's camera
110
+ * and answer Mod+=, Mod+- and Mod+0. Default `true`. */
111
+ zoom?: boolean;
107
112
  /** Tools offered lab-wide. A trial whose instrument declares none of its own
108
113
  * reflects and writes this slot. */
109
114
  tools?: readonly TrialTool[];
@@ -339,6 +344,7 @@ function LabRuntime({
339
344
  labChrome,
340
345
  suppress,
341
346
  addTrial,
347
+ zoom = true,
342
348
  tools,
343
349
  configRules,
344
350
  controls,
@@ -354,6 +360,7 @@ function LabRuntime({
354
360
  }, [instruments, store]);
355
361
 
356
362
  const trials = useStore(store, (s) => s.trials);
363
+ const [cameras] = useState(createCameraRegistry);
357
364
  const [focusPick, setFocusPick] = useState<string | null>(null);
358
365
  const focusedTrialId = trials.some((t) => t.id === focusPick)
359
366
  ? focusPick
@@ -612,79 +619,83 @@ function LabRuntime({
612
619
  <PersistenceContext.Provider value={opened.records}>
613
620
  <AnnotationPreloadContext.Provider value={opened.marks}>
614
621
  <LabContext.Provider value={contextValue}>
615
- <ThemeProvider
616
- theme={theme}
617
- selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
618
- className="lk-lab"
619
- style={backdropStyle}
620
- >
621
- <LabShell
622
- title={title ?? 'Labkit'}
623
- mode={modeValue}
624
- {...(pages ? { pages } : {})}
625
- {...(path !== undefined ? { path } : {})}
626
- footer={
627
- hasFooterChrome ? (
622
+ <CameraRegistryContext.Provider value={cameras}>
623
+ <ThemeProvider
624
+ theme={theme}
625
+ selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
626
+ className="lk-lab"
627
+ style={backdropStyle}
628
+ >
629
+ <LabShell
630
+ title={title ?? 'Labkit'}
631
+ mode={modeValue}
632
+ {...(pages ? { pages } : {})}
633
+ {...(path !== undefined ? { path } : {})}
634
+ footer={
635
+ hasFooterChrome ? (
636
+ <>
637
+ {footer}
638
+ <LabFooterRegion contributions={labChromeAll} />
639
+ </>
640
+ ) : (
641
+ footer
642
+ )
643
+ }
644
+ header={
628
645
  <>
629
- {footer}
630
- <LabFooterRegion contributions={labChromeAll} />
646
+ <LabHeader {...(addTrial !== undefined ? { addTrial } : {})} />
647
+ {zoom ? <LabZoom /> : null}
648
+ {children}
649
+ <LabHeaderRegion contributions={labChromeAll} />
650
+ <LabThemeSwitcher />
631
651
  </>
632
- ) : (
633
- footer
634
- )
635
- }
636
- header={
637
- <>
638
- <LabHeader {...(addTrial !== undefined ? { addTrial } : {})} />
639
- {children}
640
- <LabHeaderRegion contributions={labChromeAll} />
641
- </>
642
- }
643
- >
644
- <PanelHostContext.Provider value={panelHostsRef.current}>
645
- <SurfaceContext.Provider value={surface}>
646
- <SurfaceCanvasContext.Provider value={surfaceCanvases}>
647
- <div className="lk-lab__body" ref={labBodyRef}>
648
- {outerSurface ? null : (
649
- // Two buffers stacked around the trials, both inert —
650
- // each tile takes input from its own box. A tile's marks
651
- // annotate the instrument's DOM from over it; an opaque
652
- // renderer sits under it, so the pane can still hold a
653
- // label. The under one is first so it paints first.
654
- <>
655
- <canvas
656
- className="lk-lab__surface lk-lab__surface--under"
657
- ref={(el) => {
658
- ownUnderRef.current = el;
659
- setOwnUnder(el);
660
- }}
661
- />
662
- <canvas
663
- className="lk-lab__surface lk-lab__surface--over"
664
- ref={(el) => {
665
- ownOverRef.current = el;
666
- setOwnOver(el);
667
- }}
668
- />
669
- </>
670
- )}
671
- {hasPaneChrome ? (
672
- <LabPanes contributions={labChromeAll}>
673
- <LabPalette contributions={labChromeAll} />
674
- {workspace}
675
- </LabPanes>
676
- ) : (
677
- <>
678
- <LabPalette contributions={labChromeAll} />
679
- {workspace}
680
- </>
681
- )}
682
- </div>
683
- </SurfaceCanvasContext.Provider>
684
- </SurfaceContext.Provider>
685
- </PanelHostContext.Provider>
686
- </LabShell>
687
- </ThemeProvider>
652
+ }
653
+ >
654
+ <PanelHostContext.Provider value={panelHostsRef.current}>
655
+ <SurfaceContext.Provider value={surface}>
656
+ <SurfaceCanvasContext.Provider value={surfaceCanvases}>
657
+ <div className="lk-lab__body" ref={labBodyRef}>
658
+ {outerSurface ? null : (
659
+ // Two buffers stacked around the trials, both inert —
660
+ // each tile takes input from its own box. A tile's marks
661
+ // annotate the instrument's DOM from over it; an opaque
662
+ // renderer sits under it, so the pane can still hold a
663
+ // label. The under one is first so it paints first.
664
+ <>
665
+ <canvas
666
+ className="lk-lab__surface lk-lab__surface--under"
667
+ ref={(el) => {
668
+ ownUnderRef.current = el;
669
+ setOwnUnder(el);
670
+ }}
671
+ />
672
+ <canvas
673
+ className="lk-lab__surface lk-lab__surface--over"
674
+ ref={(el) => {
675
+ ownOverRef.current = el;
676
+ setOwnOver(el);
677
+ }}
678
+ />
679
+ </>
680
+ )}
681
+ {hasPaneChrome ? (
682
+ <LabPanes contributions={labChromeAll}>
683
+ <LabPalette contributions={labChromeAll} />
684
+ {workspace}
685
+ </LabPanes>
686
+ ) : (
687
+ <>
688
+ <LabPalette contributions={labChromeAll} />
689
+ {workspace}
690
+ </>
691
+ )}
692
+ </div>
693
+ </SurfaceCanvasContext.Provider>
694
+ </SurfaceContext.Provider>
695
+ </PanelHostContext.Provider>
696
+ </LabShell>
697
+ </ThemeProvider>
698
+ </CameraRegistryContext.Provider>
688
699
  </LabContext.Provider>
689
700
  </AnnotationPreloadContext.Provider>
690
701
  </PersistenceContext.Provider>
@@ -1,4 +1,4 @@
1
- // Host pages for LabFit.stories. Each is scoped by its own class, so a story
1
+ // Host pages for LabFit.browser.test. Each is scoped by its own class, so a test
2
2
  // that mounts no fixture leaves the page untouched.
3
3
 
4
4
  // The mount every example documents: `html, body, #root { margin: 0; height: 100% }`.
@@ -1,15 +1,21 @@
1
- import type { Meta, StoryObj } from '@weasel-js/forge';
2
- import { expect, userEvent, waitFor, within } from '@weasel-js/forge/play';
1
+ import '@weasel-js/theme/tokens.css';
2
+ import 'windease/styles.css';
3
+ import '../styles.less';
4
+ import './LabFit.browser.test.less';
5
+ import { cleanup, render, screen, waitFor } from '@testing-library/react';
3
6
  import { type ReactNode, useLayoutEffect, useState } from 'react';
4
7
  import { createPortal } from 'react-dom';
8
+ import { afterEach, expect, test } from 'vitest';
9
+ import { page, userEvent } from 'vitest/browser';
5
10
  import type { LabContribution } from '../chrome/labTypes';
6
11
  import type { TrialContribution } from '../chrome/types';
7
12
  import type { Instrument } from '../instrument/types';
8
13
  import { Lab } from './Lab';
9
- import './LabFit.stories.less';
10
14
 
11
- // A lab must fit whatever it is mounted in without scrolling. jsdom cannot see
12
- // layout, so these run in the forge-stories vitest project's real browser.
15
+ // A lab must fit whatever it is mounted in without scrolling, which only real
16
+ // layout can show.
17
+
18
+ afterEach(cleanup);
13
19
 
14
20
  const Stub: Instrument = {
15
21
  name: 'Stub',
@@ -158,62 +164,34 @@ function fitProblems(fixture: Fixture, viewport: keyof typeof VIEWPORTS): string
158
164
  return problems;
159
165
  }
160
166
 
161
- function fit(fixture: Fixture, viewport: keyof typeof VIEWPORTS): Story {
162
- return {
163
- globals: { viewport: { value: viewport, isRotated: false } },
164
- render: () => (
165
- <BodyHost host={HOST[fixture]}>
166
- <Lab
167
- title={`Fit: ${fixture}`}
168
- instruments={[Stub]}
169
- defaultInstrument="Stub"
170
- chrome={fixture === 'floating' ? [floater] : undefined}
171
- labChrome={fixture === 'sidebar' ? [sidebarTree] : undefined}
172
- />
173
- </BodyHost>
174
- ),
175
- play: async () => {
176
- if (fixture === 'floating') {
177
- const undock = await within(document.body).findByRole('button', { name: 'Undock Floater' });
178
- await userEvent.click(undock);
179
- }
180
- await waitFor(
181
- () => {
182
- const problems = fitProblems(fixture, viewport);
183
- if (problems.length > 0) throw new Error(problems.join('; '));
184
- },
185
- { timeout: 2000 },
186
- );
187
- await expect(fitProblems(fixture, viewport)).toEqual([]);
167
+ const FIXTURES: Fixture[] = ['fullscreen', 'floating', 'wrapped', 'framed', 'sidebar'];
168
+ const CASES = FIXTURES.flatMap((fixture) =>
169
+ (Object.keys(VIEWPORTS) as (keyof typeof VIEWPORTS)[]).map((viewport) => ({ fixture, viewport })),
170
+ );
171
+
172
+ test.each(CASES)('$fixture fits a $viewport viewport', async ({ fixture, viewport }) => {
173
+ const { width, height } = VIEWPORTS[viewport].styles;
174
+ await page.viewport(Number.parseInt(width, 10), Number.parseInt(height, 10));
175
+ render(
176
+ <BodyHost host={HOST[fixture]}>
177
+ <Lab
178
+ title={`Fit: ${fixture}`}
179
+ instruments={[Stub]}
180
+ defaultInstrument="Stub"
181
+ chrome={fixture === 'floating' ? [floater] : undefined}
182
+ labChrome={fixture === 'sidebar' ? [sidebarTree] : undefined}
183
+ />
184
+ </BodyHost>,
185
+ );
186
+ if (fixture === 'floating') {
187
+ await userEvent.click(await screen.findByRole('button', { name: 'Undock Floater' }));
188
+ }
189
+ await waitFor(
190
+ () => {
191
+ const problems = fitProblems(fixture, viewport);
192
+ if (problems.length > 0) throw new Error(problems.join('; '));
188
193
  },
189
- };
190
- }
191
-
192
- const meta: Meta<typeof Lab> = {
193
- title: 'labkit/Lab/Fit',
194
- component: Lab,
195
- parameters: { layout: 'fullscreen', viewport: { options: VIEWPORTS } },
196
- };
197
- export default meta;
198
-
199
- type Story = StoryObj<typeof Lab>;
200
-
201
- export const FullscreenWide = fit('fullscreen', 'wide');
202
- export const FullscreenMedium = fit('fullscreen', 'medium');
203
- export const FullscreenNarrow = fit('fullscreen', 'narrow');
204
-
205
- export const FloatingPanelWide = fit('floating', 'wide');
206
- export const FloatingPanelMedium = fit('floating', 'medium');
207
- export const FloatingPanelNarrow = fit('floating', 'narrow');
208
-
209
- export const WrappedNoResetWide = fit('wrapped', 'wide');
210
- export const WrappedNoResetMedium = fit('wrapped', 'medium');
211
- export const WrappedNoResetNarrow = fit('wrapped', 'narrow');
212
-
213
- export const FramedWide = fit('framed', 'wide');
214
- export const FramedMedium = fit('framed', 'medium');
215
- export const FramedNarrow = fit('framed', 'narrow');
216
-
217
- export const SidebarWide = fit('sidebar', 'wide');
218
- export const SidebarMedium = fit('sidebar', 'medium');
219
- export const SidebarNarrow = fit('sidebar', 'narrow');
194
+ { timeout: 2000 },
195
+ );
196
+ expect(fitProblems(fixture, viewport)).toEqual([]);
197
+ });
@@ -252,6 +252,7 @@ const runLog = defineInstrument<{ lines: string[] }, Record<string, never>>({
252
252
  const meta: Meta<typeof Lab> = {
253
253
  title: 'labkit/Lab/FullChrome',
254
254
  component: Lab,
255
+ tags: ['gallery'],
255
256
  parameters: { layout: 'fullscreen' },
256
257
  };
257
258
  export default meta;