@weasel-js/labkit 1.5.2 → 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 (217) hide show
  1. package/README.md +3 -7
  2. package/dist/_dts/{CanvasStackContext-CMMo180l.d.ts → CanvasStackContext-Wo6OwIVM.d.ts} +1 -1
  3. package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
  4. package/dist/_dts/LayerList.d-BoELAMsM.d.ts +83 -0
  5. package/dist/_dts/{PrefsForm.d-DPy9I8It.d.ts → PrefsRow.d-DFpPIeIC.d.ts} +16 -1
  6. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +404 -0
  7. package/dist/_dts/{Select.d-ZTiwzYyT.d.ts → Select.d-Cr8-zoup.d.ts} +105 -15
  8. package/dist/_dts/{usePanZoom-SSTZe410.d.ts → Stage-DWBBi8IU.d.ts} +80 -35
  9. package/dist/_dts/{index-DlKolEIZ.d.ts → index-CKyX0ME_.d.ts} +12 -13
  10. package/dist/_dts/{frac-B4hFJMiN.d.ts → types-B7yBTTF9.d.ts} +709 -651
  11. package/dist/_dts/{types-Db-3NNk1.d.ts → types-DTuViBvc.d.ts} +21 -2
  12. package/dist/_dts/{useTrialState-pnAE-UBD.d.ts → useTrialState-CMaV6UD4.d.ts} +2 -3
  13. package/dist/canvas/index.d.ts +41 -8
  14. package/dist/canvas/index.js +5 -3
  15. package/dist/chrome/index.d.ts +6 -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-2RVCB2PT.js +3 -0
  20. package/dist/chunk-2RVCB2PT.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-CKUKF72R.js → chunk-4TT3PNPR.js} +68 -55
  24. package/dist/chunk-4TT3PNPR.js.map +1 -0
  25. package/dist/{chunk-KJCUERQF.js → chunk-6FLXL3I7.js} +9 -164
  26. package/dist/chunk-6FLXL3I7.js.map +1 -0
  27. package/dist/chunk-ASOK76ZH.js +431 -0
  28. package/dist/chunk-ASOK76ZH.js.map +1 -0
  29. package/dist/chunk-AWQG7FCC.js +19 -0
  30. package/dist/chunk-AWQG7FCC.js.map +1 -0
  31. package/dist/chunk-CGHF6RZV.js +300 -0
  32. package/dist/chunk-CGHF6RZV.js.map +1 -0
  33. package/dist/chunk-DWMSB4CA.js +228 -0
  34. package/dist/chunk-DWMSB4CA.js.map +1 -0
  35. package/dist/chunk-GHGO52SO.js +92 -0
  36. package/dist/chunk-GHGO52SO.js.map +1 -0
  37. package/dist/{chunk-LRI3MBGR.js → chunk-HKV7LRHY.js} +4 -4
  38. package/dist/{chunk-LRI3MBGR.js.map → chunk-HKV7LRHY.js.map} +1 -1
  39. package/dist/{chunk-EMXAIRUJ.js → chunk-IRTGUQ5O.js} +5 -4
  40. package/dist/chunk-IRTGUQ5O.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-KHK5FGER.js +145 -0
  44. package/dist/chunk-KHK5FGER.js.map +1 -0
  45. package/dist/chunk-NEXR37YJ.js +144 -0
  46. package/dist/chunk-NEXR37YJ.js.map +1 -0
  47. package/dist/{chunk-A2ACAGW6.js → chunk-O6FFFOHS.js} +32 -7
  48. package/dist/chunk-O6FFFOHS.js.map +1 -0
  49. package/dist/chunk-QGMOA2YE.js +731 -0
  50. package/dist/chunk-QGMOA2YE.js.map +1 -0
  51. package/dist/{chunk-ERFMC6WU.js → chunk-S4NCI32V.js} +19 -10
  52. package/dist/chunk-S4NCI32V.js.map +1 -0
  53. package/dist/{chunk-753Y6HE6.js → chunk-SLAQPUI4.js} +4 -4
  54. package/dist/chunk-SLAQPUI4.js.map +1 -0
  55. package/dist/{chunk-REOBGYMC.js → chunk-TJ77A4A3.js} +3 -5
  56. package/dist/chunk-TJ77A4A3.js.map +1 -0
  57. package/dist/chunk-TYN6MLXX.js +13864 -0
  58. package/dist/chunk-TYN6MLXX.js.map +1 -0
  59. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  60. package/dist/chunk-WPLAANH4.js.map +1 -0
  61. package/dist/config/index.d.ts +51 -7
  62. package/dist/config/index.js +4 -5
  63. package/dist/controls/index.d.ts +92 -5
  64. package/dist/controls/index.js +5 -4
  65. package/dist/dragdrop/index.d.ts +9 -6
  66. package/dist/dragdrop/index.js +3 -2
  67. package/dist/index.d.ts +108 -154
  68. package/dist/index.js +456 -600
  69. package/dist/index.js.map +1 -1
  70. package/dist/layers/index.d.ts +5 -35
  71. package/dist/layers/index.js +3 -3
  72. package/dist/loupe/index.d.ts +21 -17
  73. package/dist/loupe/index.js +5 -3
  74. package/dist/overview/index.d.ts +56 -0
  75. package/dist/overview/index.js +288 -0
  76. package/dist/overview/index.js.map +1 -0
  77. package/dist/passthrough/weasel-ui.d.ts +434 -231
  78. package/dist/passthrough/weasel-ui.js +2 -1
  79. package/dist/primitives/index.d.ts +19 -54
  80. package/dist/primitives/index.js +6 -4
  81. package/dist/state/index.d.ts +8 -7
  82. package/dist/state/index.js +4 -5
  83. package/dist/state/index.js.map +1 -1
  84. package/dist/styles.css +360 -174
  85. package/dist/undo/index.d.ts +5 -4
  86. package/package.json +13 -13
  87. package/src/annotations/frac.test.ts +7 -0
  88. package/src/annotations/index.ts +1 -0
  89. package/src/annotations/paint.test.ts +21 -0
  90. package/src/annotations/paint.ts +12 -0
  91. package/src/annotations/store.test.ts +57 -0
  92. package/src/annotations/store.ts +25 -6
  93. package/src/annotations/toolMap.test.ts +37 -2
  94. package/src/annotations/toolMap.ts +13 -0
  95. package/src/annotations/types.ts +30 -2
  96. package/src/canvas/AGENTS.md +8 -10
  97. package/src/canvas/CameraInput.test.tsx +136 -0
  98. package/src/canvas/CameraInput.tsx +311 -0
  99. package/src/canvas/CanvasStack.tsx +42 -58
  100. package/src/canvas/LinkedCursor.less +16 -0
  101. package/src/canvas/LinkedCursor.tsx +54 -0
  102. package/src/canvas/Stage.test.tsx +18 -0
  103. package/src/canvas/Stage.tsx +42 -37
  104. package/src/canvas/cameraView.test.ts +60 -0
  105. package/src/canvas/cameraView.ts +61 -0
  106. package/src/canvas/index.ts +11 -2
  107. package/src/chrome/regions/SidebarRegion.less +197 -0
  108. package/src/chrome/regions/SidebarRegion.test.tsx +27 -0
  109. package/src/chrome/regions/SidebarRegion.tsx +7 -2
  110. package/src/chrome/types.ts +5 -2
  111. package/src/config/autoConfig.ts +2 -2
  112. package/src/config/builder.ts +21 -2
  113. package/src/config/entry.test.ts +10 -0
  114. package/src/config/fromConfigField.ts +1 -0
  115. package/src/config/index.ts +5 -0
  116. package/src/config/resolve.ts +11 -1
  117. package/src/config/rules.ts +2 -0
  118. package/src/config/sectionTree.test.ts +152 -0
  119. package/src/config/sectionTree.ts +134 -0
  120. package/src/config/types.ts +21 -0
  121. package/src/controls/ControlPanel.less +4 -0
  122. package/src/controls/ControlPanel.stories.tsx +55 -2
  123. package/src/controls/ControlPanel.test.tsx +40 -3
  124. package/src/controls/ControlPanel.tsx +134 -336
  125. package/src/controls/inDialog.test.tsx +100 -0
  126. package/src/controls/inDialog.tsx +38 -0
  127. package/src/controls/index.ts +1 -0
  128. package/src/index.test.ts +1 -1
  129. package/src/index.ts +18 -29
  130. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  131. package/src/instrument/types.ts +15 -3
  132. package/src/lab/Lab.nebula.test.tsx +40 -0
  133. package/src/lab/Lab.test.tsx +3 -1
  134. package/src/lab/Lab.tsx +42 -19
  135. package/src/lab/LabBoundary.test.tsx +66 -0
  136. package/src/lab/LabBoundary.tsx +56 -0
  137. package/src/lab/LabHeader.tsx +4 -23
  138. package/src/lab/LabRoot.tsx +2 -3
  139. package/src/lab/LabShell.less +0 -1
  140. package/src/lab/LabShell.stories.tsx +15 -0
  141. package/src/lab/LabShell.test.tsx +18 -0
  142. package/src/lab/LabShell.tsx +14 -1
  143. package/src/lab/LabSwitcher.less +1 -1
  144. package/src/lab/LabSwitcher.test.tsx +41 -0
  145. package/src/lab/LabSwitcher.tsx +30 -8
  146. package/src/lab/index.ts +1 -0
  147. package/src/layers/AGENTS.md +18 -74
  148. package/src/layers/index.ts +4 -2
  149. package/src/loupe/AGENTS.md +7 -6
  150. package/src/loupe/LoupeGestures.tsx +14 -9
  151. package/src/loupe/TrialLoupe.tsx +5 -5
  152. package/src/loupe/index.ts +1 -0
  153. package/src/loupe/loupeActions.ts +6 -2
  154. package/src/overview/AGENTS.md +38 -0
  155. package/src/overview/OverviewMarks.test.tsx +100 -0
  156. package/src/overview/OverviewMarks.tsx +93 -0
  157. package/src/overview/TrialOverview.less +53 -0
  158. package/src/overview/TrialOverview.stories.tsx +50 -0
  159. package/src/overview/TrialOverview.test.tsx +116 -0
  160. package/src/overview/TrialOverview.tsx +374 -0
  161. package/src/overview/index.ts +2 -0
  162. package/src/passthrough/weasel-ui.ts +32 -10
  163. package/src/primitives/index.ts +0 -2
  164. package/src/specimen/Specimen.tsx +59 -56
  165. package/src/state/labRecords.test.ts +4 -1
  166. package/src/state/store.test.ts +4 -4
  167. package/src/state/types.ts +2 -1
  168. package/src/state/useTrialState.test.tsx +1 -1
  169. package/src/styles.less +6 -2
  170. package/src/theme/Interstellar.stories.tsx +8 -13
  171. package/src/theme/base.less +3 -20
  172. package/src/theme/interstellar.test.ts +6 -3
  173. package/src/theme/interstellar.theme.json +0 -56
  174. package/src/theme/interstellar.ts +2 -2
  175. package/src/tools/labTool.ts +6 -2
  176. package/src/trial/Trial.annotations.test.tsx +16 -0
  177. package/src/trial/Trial.canvas.test.tsx +1 -1
  178. package/src/trial/Trial.loupe.test.tsx +73 -1
  179. package/src/trial/Trial.stories.tsx +2 -0
  180. package/src/trial/Trial.tsx +47 -9
  181. package/src/trial/trialOps.test.ts +4 -4
  182. package/dist/_dts/index-8LiKe3A-.d.ts +0 -410
  183. package/dist/chunk-2AYGEN57.js +0 -32
  184. package/dist/chunk-2AYGEN57.js.map +0 -1
  185. package/dist/chunk-6BUQ77ZV.js +0 -18
  186. package/dist/chunk-6BUQ77ZV.js.map +0 -1
  187. package/dist/chunk-753Y6HE6.js.map +0 -1
  188. package/dist/chunk-A2ACAGW6.js.map +0 -1
  189. package/dist/chunk-CKUKF72R.js.map +0 -1
  190. package/dist/chunk-D35PFTS7.js +0 -170
  191. package/dist/chunk-D35PFTS7.js.map +0 -1
  192. package/dist/chunk-EMXAIRUJ.js.map +0 -1
  193. package/dist/chunk-ERFMC6WU.js.map +0 -1
  194. package/dist/chunk-FKLT3ITC.js +0 -590
  195. package/dist/chunk-FKLT3ITC.js.map +0 -1
  196. package/dist/chunk-KJCUERQF.js.map +0 -1
  197. package/dist/chunk-LASCBWW7.js +0 -13456
  198. package/dist/chunk-LASCBWW7.js.map +0 -1
  199. package/dist/chunk-REOBGYMC.js.map +0 -1
  200. package/dist/chunk-RS3HRQWU.js.map +0 -1
  201. package/dist/chunk-TT5C5S6R.js +0 -368
  202. package/dist/chunk-TT5C5S6R.js.map +0 -1
  203. package/dist/ui/layers/index.d.ts +0 -66
  204. package/dist/ui/layers/index.js +0 -3
  205. package/dist/ui/layers/index.js.map +0 -1
  206. package/src/canvas/usePanZoom.test.ts +0 -244
  207. package/src/canvas/usePanZoom.ts +0 -147
  208. package/src/lab/useSystemMode.ts +0 -30
  209. package/src/layers/LayerList.less +0 -93
  210. package/src/layers/LayerList.stories.tsx +0 -66
  211. package/src/layers/LayerList.test.tsx +0 -232
  212. package/src/layers/LayerList.tsx +0 -268
  213. package/src/primitives/Sidebar.less +0 -112
  214. package/src/primitives/Sidebar.stories.tsx +0 -38
  215. package/src/primitives/Sidebar.test.tsx +0 -74
  216. package/src/primitives/Sidebar.tsx +0 -35
  217. package/src/ui/layers/index.ts +0 -5
@@ -0,0 +1,100 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { useState } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { f } from '../config/builder';
5
+ import { withValueAtPath } from '../config/path';
6
+ import { resolveConfigSchema } from '../config/resolve';
7
+ import { ControlPanel } from './ControlPanel';
8
+ import { summarizeValue } from './inDialog';
9
+
10
+ function Panel({ schema }: { schema: Parameters<typeof resolveConfigSchema>[0] }) {
11
+ const resolved = resolveConfigSchema(schema);
12
+ const [config, setConfig] = useState<Record<string, unknown>>(
13
+ schema.defaults() as Record<string, unknown>,
14
+ );
15
+ return (
16
+ <>
17
+ <ControlPanel
18
+ schema={resolved}
19
+ config={config}
20
+ setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
21
+ />
22
+ <output data-testid="config">{JSON.stringify(config)}</output>
23
+ </>
24
+ );
25
+ }
26
+
27
+ describe('f.list', () => {
28
+ it('summarizes the list on the row and edits it in a dialog', () => {
29
+ const schema = f.schema({
30
+ icons: f.list(['**/node_modules/**', 'dist']).label('Draw as icons'),
31
+ });
32
+ render(<Panel schema={schema} />);
33
+ const trigger = screen.getByRole('button', { name: 'Edit Draw as icons' });
34
+ expect(trigger.textContent).toContain('**/node_modules/**, dist');
35
+
36
+ fireEvent.click(trigger);
37
+ fireEvent.change(screen.getByRole('textbox', { name: 'Draw as icons 2' }), {
38
+ target: { value: 'build' },
39
+ });
40
+ expect(screen.getByTestId('config').textContent).toBe(
41
+ JSON.stringify({ icons: ['**/node_modules/**', 'build'] }),
42
+ );
43
+ });
44
+
45
+ it('is the kind an f.value with a string-array default takes', () => {
46
+ const resolved = resolveConfigSchema(f.schema({ names: f.value(['a']) }));
47
+ expect(resolved.group.children.names).toMatchObject({ kind: 'list' });
48
+ });
49
+ });
50
+
51
+ describe('.dialog', () => {
52
+ it('hosts any renderer in the dialog, handed the row context', () => {
53
+ const schema = f.schema({
54
+ note: f.string('hi').dialog(({ value, setValue }) => (
55
+ <button type="button" onClick={() => setValue(`${value}!`)}>
56
+ shout
57
+ </button>
58
+ )),
59
+ });
60
+ render(<Panel schema={schema} />);
61
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Note' }));
62
+ fireEvent.click(screen.getByRole('button', { name: 'shout' }));
63
+ expect(screen.getByTestId('config').textContent).toBe(JSON.stringify({ note: 'hi!' }));
64
+ });
65
+
66
+ it('takes a summary of its own', () => {
67
+ const summary = vi.fn((v: unknown) => `${(v as number[]).length} points`);
68
+ const schema = f.schema({ pts: f.custom('points', [1, 2, 3]).dialog(() => null, { summary }) });
69
+ render(<Panel schema={schema} />);
70
+ expect(screen.getByRole('button', { name: 'Edit Pts' }).textContent).toContain('3 points');
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
+ });
90
+ });
91
+
92
+ describe('summarizeValue', () => {
93
+ it('joins lists and names emptiness', () => {
94
+ expect(summarizeValue(['a', 'b'])).toBe('a, b');
95
+ expect(summarizeValue([])).toBe('None');
96
+ expect(summarizeValue('')).toBe('None');
97
+ expect(summarizeValue(3)).toBe('3');
98
+ expect(summarizeValue({ a: 1 })).toBe('{"a":1}');
99
+ });
100
+ });
@@ -0,0 +1,38 @@
1
+ import { DialogRow } from '@weasel-js/ui';
2
+ import type { ControlRenderer, InDialogOptions } from '../config/types';
3
+
4
+ export type { InDialogOptions };
5
+
6
+ /** The one-line summary a dialog row shows when it is given none. */
7
+ export function summarizeValue(value: unknown): string {
8
+ if (Array.isArray(value)) return value.length === 0 ? 'None' : value.join(', ');
9
+ if (value === undefined || value === null || value === '') return 'None';
10
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
11
+ }
12
+
13
+ /**
14
+ * Moves a control into a modal: the row becomes a button showing a summary of
15
+ * the value, and the button opens a dialog whose body is `body`, handed the
16
+ * same context a row renderer gets. For a leaf whose editor will never fit a
17
+ * sidebar row.
18
+ */
19
+ export function inDialog(body: ControlRenderer, opts: InDialogOptions = {}): ControlRenderer {
20
+ const summary = opts.summary ?? summarizeValue;
21
+ return (ctx) => {
22
+ const { pref, value, auto, setAuto } = ctx;
23
+ const manual = (pref as { manual?: boolean }).manual === true;
24
+ return (
25
+ <DialogRow
26
+ label={pref.name}
27
+ description={pref.description}
28
+ summary={summary(value)}
29
+ title={opts.title}
30
+ readout={auto ? 'auto' : undefined}
31
+ auto={auto}
32
+ onAutoChange={manual ? undefined : setAuto}
33
+ >
34
+ {() => body(ctx)}
35
+ </DialogRow>
36
+ );
37
+ };
38
+ }
@@ -1,4 +1,5 @@
1
1
  export { ControlPanel } from './ControlPanel';
2
+ export { type InDialogOptions, inDialog, summarizeValue } from './inDialog';
2
3
  export type {
3
4
  CheckboxField,
4
5
  ColorField,
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, LayerStack, Input, Select, TokenPanel, ToggleBar and Button live in
1
+ // The property-panel family, 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.
@@ -8,19 +8,13 @@ export type {
8
8
  ButtonProps,
9
9
  ButtonSize,
10
10
  ButtonVariant,
11
- CheckboxRowProps,
12
- ColorRowProps,
13
11
  CurveFieldProps,
14
12
  CurveMark,
15
- EffectCardListItem,
16
- EffectCardListProps,
17
- EffectCardProps,
18
13
  IconProps,
19
14
  InputProps,
20
15
  JogProps,
21
- LayerStackItem,
22
- LayerStackProps,
23
- NumberRowProps,
16
+ PropertyControlProps,
17
+ PropertyFieldProps,
24
18
  PropertyGroupProps,
25
19
  PropertyListPack,
26
20
  PropertyListProps,
@@ -31,15 +25,11 @@ export type {
31
25
  PropertyRowVariant,
32
26
  PropertySpanProps,
33
27
  SelectProps,
34
- SelectRowProps,
35
- SliderRowProps,
36
28
  SubpanelProps,
37
- TextRowProps,
38
29
  ToggleBarItem,
39
30
  ToggleBarProps,
40
31
  ToggleBarSize,
41
32
  ToggleBarVariant,
42
- ToggleRowProps,
43
33
  TokenCategory,
44
34
  TokenEntry,
45
35
  TokenPanelProps,
@@ -48,39 +38,31 @@ export type {
48
38
  } from '@weasel-js/ui';
49
39
  export {
50
40
  Button,
51
- CheckboxRow,
52
41
  CloseIcon,
53
42
  CollapseIcon,
54
- ColorRow,
55
43
  CurveField,
56
- EffectCard,
57
- EffectCardList,
58
44
  ExpandIcon,
59
45
  Icon,
60
46
  Input,
61
47
  inferTokenType,
62
48
  Jog,
63
- LayerStack,
64
- NumberRow,
65
49
  PauseIcon,
66
50
  PlayIcon,
51
+ PropertyControl,
52
+ PropertyField,
67
53
  PropertyGroup,
68
54
  PropertyList,
69
55
  PropertyPanel,
70
56
  PropertyRow,
71
57
  PropertySpan,
72
- refitScale,
73
58
  ResetIcon,
59
+ refitScale,
74
60
  Select,
75
- SelectRow,
76
- SliderRow,
77
61
  StepBackIcon,
78
62
  StepForwardIcon,
79
63
  StopIcon,
80
64
  Subpanel,
81
- TextRow,
82
65
  ToggleBar,
83
- ToggleRow,
84
66
  TokenPanel,
85
67
  } from '@weasel-js/ui';
86
68
  export type {
@@ -97,6 +79,7 @@ export type {
97
79
  AnnotationsCapability,
98
80
  AnnotationTarget,
99
81
  AnnotationTargetInfo,
82
+ AnnotationToolId,
100
83
  CaptureDeps,
101
84
  CaptureOptions,
102
85
  CaptureResult,
@@ -147,13 +130,12 @@ export {
147
130
  export type { ContentSize, PaneSize } from './annotations/view';
148
131
  export { fitView, fromWeaselView, toWeaselView } from './annotations/view';
149
132
  export type {
133
+ CameraContextValue,
150
134
  CanvasLayerDescriptor,
151
135
  CanvasStackContextValue,
152
136
  CanvasStackProps,
153
137
  CanvasStackSurface,
154
- PanZoomHandlers,
155
138
  StageProps,
156
- UsePanZoomOptions,
157
139
  ViewportSize,
158
140
  WorldFrame,
159
141
  WorldSpec,
@@ -161,6 +143,9 @@ export type {
161
143
  } from './canvas';
162
144
  export {
163
145
  applyCamera,
146
+ CameraContext,
147
+ CameraInput,
148
+ CameraScope,
164
149
  CanvasStack,
165
150
  CanvasStackContext,
166
151
  centerOn,
@@ -169,7 +154,6 @@ export {
169
154
  resolveFrame,
170
155
  Stage,
171
156
  screenToWorld,
172
- usePanZoom,
173
157
  worldToScreen,
174
158
  zoomAt,
175
159
  } from './canvas';
@@ -185,6 +169,7 @@ export {
185
169
  f,
186
170
  GroupNode,
187
171
  isConfigBranch,
172
+ ListNode,
188
173
  NumberNode,
189
174
  StringNode,
190
175
  ValueNode,
@@ -199,6 +184,7 @@ export {
199
184
  } from './config/path';
200
185
  export { resolveConfigSchema } from './config/resolve';
201
186
  export { applyRules, builtinRules, titleCase } from './config/rules';
187
+ export { type SectionTree, sectionTree } from './config/sectionTree';
202
188
  export type {
203
189
  Annotations,
204
190
  BranchAnnotations,
@@ -214,12 +200,14 @@ export type {
214
200
  ConfigSchema,
215
201
  ConfigShape,
216
202
  ControlRenderer,
203
+ DialogSpec,
217
204
  EntryValue,
218
205
  InferConfig,
219
206
  LeafPatch,
220
207
  NodeOptions,
221
208
  NodeValue,
222
209
  ResolvedConfig,
210
+ SectionOption,
223
211
  SectionSpec,
224
212
  ValueAtPath,
225
213
  } from './config/types';
@@ -227,6 +215,7 @@ export { useConfigSchema } from './config/useConfigSchema';
227
215
  export { useResolvedConfig } from './config/useResolvedConfig';
228
216
  export { isLeafVisible } from './config/visible';
229
217
  export { ControlPanel } from './controls/ControlPanel';
218
+ export { type InDialogOptions, inDialog, summarizeValue } from './controls/inDialog';
230
219
  export type {
231
220
  CheckboxField,
232
221
  ColorField,
@@ -262,8 +251,8 @@ export type { ValidationResult } from './instrument/validateConfigSchema';
262
251
  export { validateConfigSchema } from './instrument/validateConfigSchema';
263
252
  export * from './job';
264
253
  export * from './lab';
265
- export type { LayerListProps, LayerTreeNode } from './layers';
266
- export { LayerList } from './layers';
254
+ export type { LayerListItem, LayerListProps, LayerMove } from './layers';
255
+ export { LayerList, moveLayers } from './layers';
267
256
  export * from './loupe';
268
257
  export * from './primitives';
269
258
  export { SPECIMEN_SECTIONS, Specimen } from './specimen/Specimen';
@@ -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;
@@ -70,7 +79,7 @@ export interface CanvasCapability<TS = unknown, TC = unknown> {
70
79
  /** Declares that an instrument's DOM is content of a fixed size, which the
71
80
  * trial pans and zooms the way it does a canvas's layers. Not combined with
72
81
  * `canvas`: there the DOM is an unzoomed overlay, and `canvas` wins. */
73
- export interface StageCapability {
82
+ export interface StageCapability<TS = unknown, TC = unknown> {
74
83
  /** The content's own size in CSS pixels, at zoom 1. */
75
84
  size: ViewportSize;
76
85
  /** Where the view starts. Omitted, the content opens centered and shrunk to
@@ -78,6 +87,9 @@ export interface StageCapability {
78
87
  initialView?: ViewTransform | ((viewport: ViewportSize) => ViewTransform);
79
88
  minZoom?: number;
80
89
  maxZoom?: number;
90
+ /** Drawn over the content in viewport pixels, outside the camera — a legend,
91
+ * a floating panel, anything that must not zoom with the picture. */
92
+ overlay?: (ctx: RenderContext<TS, TC>) => ReactNode;
81
93
  }
82
94
 
83
95
  /** Declares which of an instrument's layers the trial should offer
@@ -159,7 +171,7 @@ export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
159
171
  serialize?: (state: TS) => unknown;
160
172
  deserialize?: (data: unknown, config: TC) => TS;
161
173
  canvas?: CanvasCapability<TS, TC>;
162
- stage?: StageCapability;
174
+ stage?: StageCapability<TS, TC>;
163
175
  layers?: LayerCapability;
164
176
  dragDrop?: DragDropCapability<TS, TC>;
165
177
  undo?: UndoCapability;
@@ -0,0 +1,40 @@
1
+ import { render } from '@testing-library/react';
2
+ import { resolveTheme } from '@weasel-js/theme';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { Instrument } from '../instrument/types';
5
+ import { interstellarTheme } from '../theme/interstellar';
6
+ import { Lab } from './Lab';
7
+
8
+ const bare: Instrument = {
9
+ name: 'Bare',
10
+ defaultConfig: () => ({}),
11
+ initialState: () => ({}),
12
+ render: () => null,
13
+ };
14
+
15
+ const backdrop = (container: HTMLElement) =>
16
+ [...container.querySelectorAll<HTMLElement>('[style]')]
17
+ .map((el) => el.style.getPropertyValue('--wzl-backdrop'))
18
+ .find(Boolean) ?? '';
19
+
20
+ describe('<Lab nebula>', () => {
21
+ it('draws one blob per literal color', () => {
22
+ const { container } = render(
23
+ <Lab
24
+ instruments={[bare]}
25
+ defaultInstrument="Bare"
26
+ mode="dark"
27
+ nebula={['#ff0000', '#00ff00']}
28
+ />,
29
+ );
30
+ expect(backdrop(container).match(/radial-gradient/g)).toHaveLength(3);
31
+ });
32
+
33
+ it('resolves a theme ramp against the lab’s theme', () => {
34
+ const { container } = render(
35
+ <Lab instruments={[bare]} defaultInstrument="Bare" mode="dark" nebula={{ ramp: 'swatch' }} />,
36
+ );
37
+ const sky = resolveTheme(interstellarTheme, { mode: 'dark' })['--wzl-swatch-sky'];
38
+ expect(backdrop(container)).toContain(sky);
39
+ });
40
+ });
@@ -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
 
package/src/lab/Lab.tsx CHANGED
@@ -1,4 +1,5 @@
1
- import { ThemeProvider } from '@weasel-js/theme/react';
1
+ import { type ColorList, colorAt, colorCount, resolveTheme, type Theme } from '@weasel-js/theme';
2
+ import { ThemeProvider, useResolvedColorMode } from '@weasel-js/theme/react';
2
3
  import {
3
4
  type CSSProperties,
4
5
  type ReactNode,
@@ -11,7 +12,7 @@ import {
11
12
  } from 'react';
12
13
  import { useStore } from 'zustand/react';
13
14
  import { AnnotationPreloadContext } from '../annotations/preload';
14
- import { ANNOTATION_TOOLS } from '../annotations/toolMap';
15
+ import { labAnnotationTools } from '../annotations/toolMap';
15
16
  import {
16
17
  LabAsideRegion,
17
18
  LabFooterRegion,
@@ -54,7 +55,6 @@ import { LabPalette } from './LabPalette';
54
55
  import { LabShell } from './LabShell';
55
56
  import type { LabPage } from './LabSwitcher';
56
57
  import { createPanelHostRegistry, PanelHostContext } from './panelHost';
57
- import { useResolvedMode } from './useSystemMode';
58
58
  import { type PanelDescriptor, type TrialLayout, Workspace } from './Workspace';
59
59
 
60
60
  interface LabBaseProps {
@@ -68,15 +68,20 @@ interface LabBaseProps {
68
68
  * window is the whole app, rather than a panel beside one, reads better at
69
69
  * `'roomy'`. The trials' own contents are unaffected. */
70
70
  density?: LabDensity;
71
+ /** The theme the lab's chrome resolves against, applied at `mode` and
72
+ * `density`. Default `interstellarTheme`; extend it rather than replace it to
73
+ * keep the lab's look while changing a few tokens. */
74
+ theme?: Theme;
71
75
  /** Rendered while a stored lab loads. Default: the lab's empty shell. */
72
76
  fallback?: ReactNode;
73
77
  /**
74
- * Optional list of CSS colors used to compose the interstellar theme's
75
- * cosmic backdrop. Each color becomes one radial-gradient blob on the
76
- * dark void base. Order maps to a fixed spread of positions; extras wrap
77
- * around. Ignored unless the resolved mode is dark.
78
+ * Colors composing the interstellar theme's cosmic backdrop. Each becomes
79
+ * one radial-gradient blob on the dark void base, in a fixed spread of
80
+ * positions; extras wrap around. A theme ramp resolves against the lab's
81
+ * theme; a function list gives as many blobs as there are positions.
82
+ * Ignored unless the resolved mode is dark.
78
83
  */
79
- nebula?: readonly string[];
84
+ nebula?: ColorList;
80
85
  title?: string;
81
86
  /** The project's other labs. Given two or more, the title becomes the way to
82
87
  * reach them — the same switcher `<LabShell>` renders, so a lab reached from
@@ -180,13 +185,14 @@ function LabFallback({
180
185
  title,
181
186
  mode,
182
187
  density,
188
+ theme = interstellarTheme,
183
189
  pages,
184
190
  path,
185
- }: Pick<LabBaseProps, 'title' | 'mode' | 'density' | 'pages' | 'path'>) {
186
- const resolvedMode = useResolvedMode(mode ?? 'auto');
191
+ }: Pick<LabBaseProps, 'title' | 'mode' | 'density' | 'theme' | 'pages' | 'path'>) {
192
+ const resolvedMode = useResolvedColorMode(mode ?? 'auto');
187
193
  return (
188
194
  <ThemeProvider
189
- theme={interstellarTheme}
195
+ theme={theme}
190
196
  selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
191
197
  className="lk-lab"
192
198
  >
@@ -214,7 +220,15 @@ const NEBULA_SLOTS = [
214
220
  { cx: '85%', cy: '18%', sx: '55%', sy: '80%', stop: '70%' },
215
221
  ] as const;
216
222
 
217
- function buildNebula(colors: readonly string[]): string {
223
+ function buildNebula(
224
+ list: ColorList,
225
+ theme: Theme,
226
+ mode: 'light' | 'dark',
227
+ density: string,
228
+ ): string {
229
+ const ctx = { theme, resolved: resolveTheme(theme, { mode, density }) };
230
+ const n = colorCount(list, ctx) ?? NEBULA_SLOTS.length;
231
+ const colors = Array.from({ length: n }, (_, i) => colorAt(list, i, ctx));
218
232
  const blobs = colors.map((c, i) => {
219
233
  const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length];
220
234
  return `radial-gradient(ellipse ${p.sx} ${p.sy} at ${p.cx} ${p.cy}, color-mix(in srgb, ${c} 22%, transparent), transparent ${p.stop})`;
@@ -302,6 +316,7 @@ export function Lab(props: LabProps) {
302
316
  title={props.title}
303
317
  mode={props.mode}
304
318
  density={props.density}
319
+ {...(props.theme ? { theme: props.theme } : {})}
305
320
  pages={props.pages}
306
321
  path={props.path}
307
322
  />
@@ -314,6 +329,7 @@ function LabRuntime({
314
329
  instruments,
315
330
  mode,
316
331
  density,
332
+ theme = interstellarTheme,
317
333
  nebula,
318
334
  title,
319
335
  pages,
@@ -455,17 +471,17 @@ function LabRuntime({
455
471
  })),
456
472
  [undockedPanels],
457
473
  );
458
- const resolvedMode = useResolvedMode(modeValue);
474
+ const resolvedMode = useResolvedColorMode(modeValue);
459
475
 
460
476
  // Tools and lab contributions share one id namespace, so they merge once
461
477
  // here — before any region renders — and a collision throws rather than one
462
478
  // of them silently losing.
463
479
  // Declaring `annotations` on any instrument puts the drawing tools here, in
464
480
  // the lab's rail: one tool is armed across every trial.
465
- const annotates = instruments.some((i) => i.annotations != null);
481
+ const annotationTools = useMemo(() => labAnnotationTools(instruments), [instruments]);
466
482
  const labChromeAll = useMemo(
467
- () => labContributions([...(annotates ? ANNOTATION_TOOLS : []), ...(tools ?? [])], labChrome),
468
- [annotates, tools, labChrome],
483
+ () => labContributions([...annotationTools, ...(tools ?? [])], labChrome),
484
+ [annotationTools, tools, labChrome],
469
485
  );
470
486
  const hasFooterChrome = labChromeAll.some((c) => c.region === 'footer');
471
487
  const hasPaneChrome = labChromeAll.some((c) => c.region === 'sidebar' || c.region === 'aside');
@@ -564,8 +580,15 @@ function LabRuntime({
564
580
  // Only override the backdrop in dark, where there is one to override.
565
581
  // Setting a CSS custom property is the sanctioned use of inline style.
566
582
  const backdropStyle =
567
- resolvedMode === 'dark' && nebula && nebula.length > 0
568
- ? ({ ['--wzl-backdrop' as string]: buildNebula(nebula) } as CSSProperties)
583
+ resolvedMode === 'dark' && nebula && colorCount(nebula) !== 0
584
+ ? ({
585
+ ['--wzl-backdrop' as string]: buildNebula(
586
+ nebula,
587
+ theme,
588
+ resolvedMode,
589
+ density ?? 'comfortable',
590
+ ),
591
+ } as CSSProperties)
569
592
  : undefined;
570
593
 
571
594
  const workspace = (
@@ -590,7 +613,7 @@ function LabRuntime({
590
613
  <AnnotationPreloadContext.Provider value={opened.marks}>
591
614
  <LabContext.Provider value={contextValue}>
592
615
  <ThemeProvider
593
- theme={interstellarTheme}
616
+ theme={theme}
594
617
  selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
595
618
  className="lk-lab"
596
619
  style={backdropStyle}
@@ -0,0 +1,66 @@
1
+ import { render } from '@testing-library/react';
2
+ import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
3
+ import { useContext } from 'react';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { LabStoreContext, TrialIdContext } from '../state/context';
6
+ import { PersistenceContext } from '../state/Persistence';
7
+ import { interstellarTheme } from '../theme/interstellar';
8
+ import { LabBoundary } from './LabBoundary';
9
+ import { LabContext, type LabContextValue } from './LabContext';
10
+ import { LabRoot } from './LabRoot';
11
+
12
+ function Probe() {
13
+ const lab = useContext(LabContext);
14
+ const store = useContext(LabStoreContext);
15
+ const trial = useContext(TrialIdContext);
16
+ const records = useContext(PersistenceContext);
17
+ const theme = useThemeOptional();
18
+ return (
19
+ <output>
20
+ {JSON.stringify({
21
+ lab: lab !== null,
22
+ store: store !== null,
23
+ trial,
24
+ records: records !== null,
25
+ theme: theme !== null,
26
+ })}
27
+ </output>
28
+ );
29
+ }
30
+
31
+ const fakeLab = { trials: [], instruments: [] } as unknown as LabContextValue;
32
+
33
+ describe('LabBoundary', () => {
34
+ it('hides the enclosing lab, trial, records and theme from its children', () => {
35
+ const { container } = render(
36
+ <ThemeProvider theme={interstellarTheme} selection={{ mode: 'dark' }}>
37
+ <LabContext.Provider value={fakeLab}>
38
+ <TrialIdContext.Provider value="t1">
39
+ <Probe />
40
+ <LabBoundary>
41
+ <Probe />
42
+ </LabBoundary>
43
+ </TrialIdContext.Provider>
44
+ </LabContext.Provider>
45
+ </ThemeProvider>,
46
+ );
47
+ const [outside, inside] = Array.from(container.querySelectorAll('output')).map((el) =>
48
+ JSON.parse(el.textContent ?? ''),
49
+ );
50
+ expect(outside).toEqual({ lab: true, store: false, trial: 't1', records: false, theme: true });
51
+ expect(inside).toEqual({ lab: false, store: false, trial: null, records: false, theme: false });
52
+ });
53
+
54
+ it('lets a LabRoot under it apply its own theme instead of deferring to the one above', () => {
55
+ const { container } = render(
56
+ <ThemeProvider theme={interstellarTheme} selection={{ mode: 'dark' }}>
57
+ <LabBoundary>
58
+ <LabRoot mode="light">x</LabRoot>
59
+ </LabBoundary>
60
+ </ThemeProvider>,
61
+ );
62
+ const themed = container.querySelectorAll('[data-wzl-theme]');
63
+ expect(themed).toHaveLength(2);
64
+ expect(themed[1]?.getAttribute('data-wzl-mode')).toBe('light');
65
+ });
66
+ });