@weasel-js/labkit 1.2.0 → 1.3.0-pre.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 (196) hide show
  1. package/dist/_dts/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
  2. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  3. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  4. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  5. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  6. package/dist/_dts/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
  7. package/dist/canvas/index.d.ts +7 -3
  8. package/dist/canvas/index.js +2 -1
  9. package/dist/chrome/index.d.ts +86 -0
  10. package/dist/chrome/index.js +8 -0
  11. package/dist/chrome/index.js.map +1 -0
  12. package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
  13. package/dist/chunk-4TMMVIDM.js.map +1 -0
  14. package/dist/{chunk-THBG7FQZ.js → chunk-HFDVGF4X.js} +15 -12
  15. package/dist/chunk-HFDVGF4X.js.map +1 -0
  16. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  17. package/dist/chunk-NS54R52R.js.map +1 -0
  18. package/dist/{chunk-G5TJVQQT.js → chunk-O2BULFHX.js} +5 -14
  19. package/dist/chunk-O2BULFHX.js.map +1 -0
  20. package/dist/chunk-O7NVSCLN.js +266 -0
  21. package/dist/chunk-O7NVSCLN.js.map +1 -0
  22. package/dist/chunk-PO6L3NSH.js +366 -0
  23. package/dist/chunk-PO6L3NSH.js.map +1 -0
  24. package/dist/chunk-PQJ5B2U2.js +27 -0
  25. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  26. package/dist/chunk-RVNN4CHQ.js +212 -0
  27. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  28. package/dist/chunk-TLGRYALP.js +6151 -0
  29. package/dist/chunk-TLGRYALP.js.map +1 -0
  30. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  31. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  32. package/dist/{chunk-BOHF3PQO.js → chunk-Z6HJ5FEM.js} +8111 -5190
  33. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  34. package/dist/chunk-ZZAYVX4X.js +505 -0
  35. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  36. package/dist/controls/index.d.ts +19 -5
  37. package/dist/controls/index.js +3 -1
  38. package/dist/dragdrop/index.d.ts +4 -2
  39. package/dist/index.d.ts +246 -106
  40. package/dist/index.js +673 -559
  41. package/dist/index.js.map +1 -1
  42. package/dist/layers/index.d.ts +5 -3
  43. package/dist/layers/index.js +2 -1
  44. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  45. package/dist/passthrough/weasel-canvas.js +1 -1
  46. package/dist/passthrough/weasel-ui.d.ts +3799 -3672
  47. package/dist/passthrough/weasel-ui.js +2 -2
  48. package/dist/primitives/index.d.ts +102 -8
  49. package/dist/primitives/index.js +4 -1
  50. package/dist/state/index.d.ts +3 -2
  51. package/dist/state/index.js +2 -2
  52. package/dist/styles.css +542 -153
  53. package/dist/surface/index.js +2 -1
  54. package/dist/ui/layers/index.js +4 -3
  55. package/dist/undo/index.d.ts +4 -2
  56. package/package.json +16 -7
  57. package/src/canvas/AGENTS.md +4 -0
  58. package/src/canvas/CanvasStack.tsx +6 -2
  59. package/src/canvas/useLayerScheduler.ts +22 -15
  60. package/src/canvas/usePanZoom.test.ts +80 -0
  61. package/src/canvas/usePanZoom.ts +16 -2
  62. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  63. package/src/chrome/builtins.test.ts +153 -0
  64. package/src/chrome/builtins.tsx +205 -0
  65. package/src/chrome/index.ts +25 -0
  66. package/src/chrome/merge.test.ts +51 -0
  67. package/src/chrome/merge.ts +52 -0
  68. package/src/chrome/regions/PaletteRegion.less +14 -0
  69. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  70. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  71. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  72. package/src/chrome/regions/StatusRegion.tsx +31 -0
  73. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  74. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  75. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  76. package/src/chrome/regions/ViewportRegion.less +42 -0
  77. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  78. package/src/chrome/regions/regions.test.tsx +81 -0
  79. package/src/chrome/types.ts +127 -0
  80. package/src/config/builder.test.ts +87 -0
  81. package/src/config/builder.ts +224 -0
  82. package/src/config/fromConfigField.test.ts +93 -0
  83. package/src/config/fromConfigField.ts +74 -0
  84. package/src/config/index.ts +22 -0
  85. package/src/config/resolve.test.ts +120 -0
  86. package/src/config/resolve.ts +67 -0
  87. package/src/config/rules.ts +68 -0
  88. package/src/config/types.ts +108 -0
  89. package/src/config/useConfigSchema.ts +30 -0
  90. package/src/config/visible.ts +20 -0
  91. package/src/controls/ControlPanel.less +4 -58
  92. package/src/controls/ControlPanel.stories.tsx +86 -0
  93. package/src/controls/ControlPanel.test.tsx +223 -10
  94. package/src/controls/ControlPanel.tsx +202 -203
  95. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  96. package/src/dragdrop/DragGhost.less +1 -1
  97. package/src/dragdrop/Palette.less +1 -1
  98. package/src/index.ts +25 -3
  99. package/src/instrument/SineWave.smoke.test.tsx +8 -1
  100. package/src/instrument/defineInstrument.test.ts +38 -1
  101. package/src/instrument/defineInstrument.ts +31 -4
  102. package/src/instrument/index.ts +1 -0
  103. package/src/instrument/types.ts +27 -4
  104. package/src/instrument/validateConfigSchema.test.ts +13 -3
  105. package/src/instrument/validateConfigSchema.ts +7 -10
  106. package/src/lab/Lab.chrome.test.tsx +65 -0
  107. package/src/lab/Lab.tsx +58 -15
  108. package/src/lab/LabContext.ts +6 -0
  109. package/src/lab/LabFullChrome.stories.tsx +308 -0
  110. package/src/lab/LabHeader.test.tsx +42 -0
  111. package/src/lab/LabHeader.tsx +57 -0
  112. package/src/lab/LabPalette.test.tsx +35 -0
  113. package/src/lab/LabPalette.tsx +34 -0
  114. package/src/lab/LabShell.less +50 -3
  115. package/src/lab/Workspace.less +1 -30
  116. package/src/lab/Workspace.test.tsx +20 -10
  117. package/src/lab/Workspace.tsx +5 -8
  118. package/src/lab/index.ts +2 -0
  119. package/src/layers/AGENTS.md +1 -1
  120. package/src/layers/LayerList.less +31 -7
  121. package/src/layers/LayerList.tsx +17 -4
  122. package/src/passthrough/weasel-ui.ts +23 -0
  123. package/src/primitives/DragHandleGlyph.tsx +29 -0
  124. package/src/primitives/FloatingPanel.less +28 -0
  125. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  126. package/src/primitives/FloatingPanel.test.tsx +240 -0
  127. package/src/primitives/FloatingPanel.tsx +190 -0
  128. package/src/primitives/FpsMeter.test.tsx +44 -0
  129. package/src/primitives/FpsMeter.tsx +20 -9
  130. package/src/primitives/JobProgress.less +69 -0
  131. package/src/primitives/JobProgress.stories.tsx +48 -0
  132. package/src/primitives/JobProgress.tsx +65 -0
  133. package/src/primitives/Legend.less +47 -0
  134. package/src/primitives/Legend.stories.tsx +32 -0
  135. package/src/primitives/Legend.test.tsx +57 -0
  136. package/src/primitives/Legend.tsx +40 -0
  137. package/src/primitives/Sidebar.less +32 -2
  138. package/src/primitives/StatusBar.less +7 -0
  139. package/src/primitives/StatusBar.tsx +10 -3
  140. package/src/primitives/Toolbar.less +50 -3
  141. package/src/primitives/Toolbar.test.tsx +67 -1
  142. package/src/primitives/Toolbar.tsx +72 -6
  143. package/src/primitives/ZoomControl.less +31 -0
  144. package/src/primitives/ZoomControl.tsx +76 -0
  145. package/src/primitives/floating.entry.test.tsx +14 -0
  146. package/src/primitives/index.ts +10 -2
  147. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  148. package/src/primitives/useRovingTabIndex.ts +79 -0
  149. package/src/state/store.ts +11 -3
  150. package/src/state/toolSlot.test.ts +36 -0
  151. package/src/state/types.ts +4 -0
  152. package/src/styles.less +6 -0
  153. package/src/surface/useTiledSurface.ts +14 -10
  154. package/src/test-setup.ts +26 -0
  155. package/src/theme/base.less +19 -2
  156. package/src/theme/interstellar.tokens.json +6 -2
  157. package/src/tools/types.ts +24 -0
  158. package/src/trial/Trial.config.test.tsx +113 -0
  159. package/src/trial/Trial.job.test.tsx +13 -15
  160. package/src/trial/Trial.less +115 -10
  161. package/src/trial/Trial.stories.tsx +1 -0
  162. package/src/trial/Trial.test.tsx +67 -96
  163. package/src/trial/Trial.tsx +80 -39
  164. package/src/trial/TrialChrome.tsx +99 -80
  165. package/src/trial/TrialDragContext.ts +10 -0
  166. package/src/trial/TrialTitleBar.tsx +41 -0
  167. package/src/trial/index.ts +1 -15
  168. package/src/trial/trialChrome.borders.test.ts +38 -0
  169. package/src/ui/layers/LayerStack.less +9 -9
  170. package/src/ui/layers/LayerStack.tsx +1 -13
  171. package/src/ui/properties/CurveField.less +2 -2
  172. package/src/ui/properties/CurveField.test.tsx +11 -11
  173. package/src/ui/properties/PropertyGroup.less +2 -2
  174. package/src/ui/properties/PropertyPanel.less +55 -41
  175. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  176. package/src/ui/properties/storyLayouts.tsx +2 -2
  177. package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
  178. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  179. package/dist/chunk-574LJAV4.js.map +0 -1
  180. package/dist/chunk-5R2ATYPJ.js +0 -201
  181. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  182. package/dist/chunk-73KA7WBO.js +0 -5992
  183. package/dist/chunk-73KA7WBO.js.map +0 -1
  184. package/dist/chunk-BOHF3PQO.js.map +0 -1
  185. package/dist/chunk-G5TJVQQT.js.map +0 -1
  186. package/dist/chunk-PMAU3SEE.js +0 -126
  187. package/dist/chunk-PMAU3SEE.js.map +0 -1
  188. package/dist/chunk-SFL7NFKN.js.map +0 -1
  189. package/dist/chunk-THBG7FQZ.js.map +0 -1
  190. package/dist/chunk-VUU5UXHE.js.map +0 -1
  191. package/src/instrument/capabilityDetector.test.ts +0 -64
  192. package/src/instrument/capabilityDetector.ts +0 -20
  193. package/src/trial/DefaultSidebar.tsx +0 -29
  194. package/src/trial/DefaultStatusBar.tsx +0 -19
  195. package/src/trial/DefaultToolbar.tsx +0 -92
  196. package/src/trial/slotTypes.ts +0 -54
@@ -1,4 +1,4 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { fireEvent, render, screen, within } from '@testing-library/react';
2
2
  import type { ReactNode } from 'react';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
4
  import type { Instrument } from '../instrument/types';
@@ -6,92 +6,9 @@ import { Lab } from '../lab/Lab';
6
6
  import { LabContext, type LabContextValue } from '../lab/LabContext';
7
7
  import { LabStoreContext } from '../state/context';
8
8
  import { createLabStore } from '../state/store';
9
- import type { TrialRecord } from '../state/types';
10
- import { DefaultToolbar } from './DefaultToolbar';
11
- import type { TrialToolbarContext } from './slotTypes';
9
+ import type { SavedSnapshot, TrialRecord } from '../state/types';
12
10
  import { TrialChrome } from './TrialChrome';
13
11
 
14
- function makeCtx(overrides: Partial<TrialToolbarContext> = {}): TrialToolbarContext {
15
- return {
16
- trialId: 'ws-1',
17
- instrumentName: 'Stub',
18
- hasUndo: false,
19
- canUndo: false,
20
- canRedo: false,
21
- undo: vi.fn(),
22
- redo: vi.fn(),
23
- zoom: 1,
24
- setZoom: vi.fn(),
25
- zoomIn: vi.fn(),
26
- zoomOut: vi.fn(),
27
- resetZoom: vi.fn(),
28
- hasCanvas: false,
29
- savedSnapshots: [],
30
- saveSnapshot: vi.fn(),
31
- loadSnapshot: vi.fn(),
32
- clone: vi.fn(),
33
- reset: vi.fn(),
34
- close: vi.fn(),
35
- isLastTrial: false,
36
- ...overrides,
37
- };
38
- }
39
-
40
- describe('<DefaultToolbar>', () => {
41
- it('renders close button', () => {
42
- render(<DefaultToolbar ctx={makeCtx()} />);
43
- expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
44
- });
45
-
46
- it('disables close when isLastTrial is true', () => {
47
- render(<DefaultToolbar ctx={makeCtx({ isLastTrial: true })} />);
48
- expect(screen.getByRole('button', { name: /close/i })).toBeDisabled();
49
- });
50
-
51
- it('omits undo/redo buttons when hasUndo is false', () => {
52
- render(<DefaultToolbar ctx={makeCtx({ hasUndo: false })} />);
53
- expect(screen.queryByRole('button', { name: /undo/i })).toBeNull();
54
- expect(screen.queryByRole('button', { name: /redo/i })).toBeNull();
55
- });
56
-
57
- it('shows undo/redo buttons when hasUndo is true', () => {
58
- render(<DefaultToolbar ctx={makeCtx({ hasUndo: true, canUndo: true, canRedo: false })} />);
59
- expect(screen.getByRole('button', { name: /undo/i })).not.toBeDisabled();
60
- expect(screen.getByRole('button', { name: /redo/i })).toBeDisabled();
61
- });
62
-
63
- it('omits zoom buttons when hasCanvas is false', () => {
64
- render(<DefaultToolbar ctx={makeCtx({ hasCanvas: false })} />);
65
- expect(screen.queryByTitle('Zoom in')).toBeNull();
66
- expect(screen.queryByTitle('Zoom out')).toBeNull();
67
- });
68
-
69
- it('shows zoom controls when hasCanvas is true', () => {
70
- render(<DefaultToolbar ctx={makeCtx({ hasCanvas: true, zoom: 1.5 })} />);
71
- expect(screen.getByTitle('Zoom in')).toBeInTheDocument();
72
- expect(screen.getByTitle('Zoom out')).toBeInTheDocument();
73
- expect(screen.getByText('150%')).toBeInTheDocument();
74
- });
75
-
76
- it('shows load select when snapshots exist', () => {
77
- const ctx = makeCtx({
78
- savedSnapshots: [
79
- {
80
- id: 's1',
81
- name: 'First',
82
- trialId: 'ws-1',
83
- instrumentName: 'Stub',
84
- config: {},
85
- state: {},
86
- savedAt: 1,
87
- },
88
- ],
89
- });
90
- render(<DefaultToolbar ctx={ctx} />);
91
- expect(screen.getByRole('combobox', { name: /load snapshot/i })).toBeInTheDocument();
92
- });
93
- });
94
-
95
12
  const stubInstrument: Instrument = {
96
13
  name: 'Stub',
97
14
  defaultConfig: () => ({}),
@@ -113,6 +30,7 @@ type ChromeProps = Parameters<typeof TrialChrome>[0];
113
30
  function ChromeHarness({
114
31
  children,
115
32
  labOverrides,
33
+ instrument = stubInstrument,
116
34
  ...props
117
35
  }: { children?: ReactNode; labOverrides?: Partial<LabContextValue> } & Partial<ChromeProps>) {
118
36
  const store = createLabStore({
@@ -120,7 +38,7 @@ function ChromeHarness({
120
38
  storage: { read: () => null, write: () => {} },
121
39
  });
122
40
  const labCtx: LabContextValue = {
123
- instruments: [stubInstrument],
41
+ instruments: [instrument],
124
42
  trials: [stubRecord],
125
43
  addTrial: vi.fn(),
126
44
  cloneTrial: vi.fn(),
@@ -141,7 +59,7 @@ function ChromeHarness({
141
59
  <TrialChrome
142
60
  trialId="ws-1"
143
61
  record={stubRecord}
144
- instrument={stubInstrument}
62
+ instrument={instrument}
145
63
  isLastTrial={false}
146
64
  {...props}
147
65
  >
@@ -158,15 +76,47 @@ describe('<TrialChrome>', () => {
158
76
  expect(screen.getByTestId('content')).toBeInTheDocument();
159
77
  });
160
78
 
161
- it('renders default toolbar when no toolbar prop is given', () => {
79
+ it('renders the built-in trial actions', () => {
80
+ render(<ChromeHarness />);
81
+ expect(screen.getByRole('button', { name: 'Close trial' })).toBeInTheDocument();
82
+ expect(screen.getByRole('button', { name: 'Clone trial' })).toBeInTheDocument();
83
+ });
84
+
85
+ it('disables close on the last trial', () => {
86
+ render(<ChromeHarness isLastTrial />);
87
+ expect(screen.getByRole('button', { name: /close/i })).toBeDisabled();
88
+ });
89
+
90
+ it('omits undo and redo unless the instrument declares undo', () => {
162
91
  render(<ChromeHarness />);
163
- expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
92
+ expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
93
+ render(
94
+ <ChromeHarness
95
+ instrument={{ ...stubInstrument, undo: {} }}
96
+ undoBindings={{ canUndo: true, canRedo: false, undo: vi.fn(), redo: vi.fn() }}
97
+ />,
98
+ );
99
+ expect(screen.getByRole('button', { name: 'Undo' })).not.toBeDisabled();
100
+ expect(screen.getByRole('button', { name: 'Redo' })).toBeDisabled();
101
+ });
102
+
103
+ it('offers the snapshot loader once a snapshot exists', () => {
104
+ const snapshot: SavedSnapshot = {
105
+ id: 's1',
106
+ name: 'First',
107
+ trialId: 'ws-1',
108
+ instrumentName: 'Stub',
109
+ config: {},
110
+ state: {},
111
+ savedAt: 1,
112
+ };
113
+ render(<ChromeHarness labOverrides={{ savedSnapshots: [snapshot] }} />);
114
+ expect(screen.getByRole('button', { name: /load snapshot/i })).toBeInTheDocument();
164
115
  });
165
116
 
166
- it('renders custom toolbar when toolbar prop is provided', () => {
167
- render(<ChromeHarness toolbar={() => <div data-testid="custom-toolbar">custom</div>} />);
168
- expect(screen.getByTestId('custom-toolbar')).toBeInTheDocument();
169
- expect(screen.queryByRole('button', { name: /close/i })).toBeNull();
117
+ it('renders a consumer contribution alongside the built-ins', () => {
118
+ render(<ChromeHarness chrome={[{ id: 'mine', region: 'status', item: { text: 'ready' } }]} />);
119
+ expect(screen.getByText('ready')).toBeInTheDocument();
170
120
  });
171
121
 
172
122
  it('Cmd+S triggers saveSnapshot', () => {
@@ -178,10 +128,31 @@ describe('<TrialChrome>', () => {
178
128
  });
179
129
  });
180
130
 
181
- describe('<Lab> + TrialChrome integration', () => {
131
+ describe('chrome regions in a mounted lab', () => {
132
+ function renderLabWith(instrument: Instrument) {
133
+ return render(<Lab title="T" instruments={[instrument]} defaultInstrument={instrument.name} />);
134
+ }
135
+
136
+ it('puts zoom in the viewport region and not in the toolbar', () => {
137
+ renderLabWith({ ...stubInstrument, canvas: { layers: [] }, undo: {} });
138
+ const toolbar = document.querySelector('.lk-trial__toolbar') as HTMLElement;
139
+ const viewport = document.querySelector('.lk-viewport-controls') as HTMLElement;
140
+ expect(within(viewport).getByRole('button', { name: 'Zoom in' })).toBeInTheDocument();
141
+ expect(within(toolbar).queryByRole('button', { name: 'Zoom in' })).toBeNull();
142
+ });
143
+
144
+ it('renders no undo group for an instrument that does not declare undo', () => {
145
+ renderLabWith({ ...stubInstrument, canvas: { layers: [] } });
146
+ expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
147
+ });
148
+
149
+ it('renders no viewport region for an instrument with no canvas', () => {
150
+ renderLabWith(stubInstrument);
151
+ expect(document.querySelector('.lk-viewport-controls')).toBeNull();
152
+ });
153
+
182
154
  it('Lab provides context for nested TrialChrome', () => {
183
- render(<Lab instruments={[stubInstrument]} defaultInstrument="Stub" />);
184
- // Lab seeds a trial; no chrome rendered without children — sanity check Lab mounts.
155
+ renderLabWith(stubInstrument);
185
156
  expect(document.querySelector('.lk-lab')).toBeTruthy();
186
157
  });
187
158
  });
@@ -2,14 +2,10 @@ import { type ReactNode, useContext, useMemo, useRef, useState } from 'react';
2
2
  import { useStore } from 'zustand/react';
3
3
  import { CanvasStack } from '../canvas/CanvasStack';
4
4
  import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
5
+ import type { TrialContribution } from '../chrome/types';
5
6
  import { DragOverlay, useDragDrop } from '../dragdrop/DragDropRuntime';
6
7
  import { Palette } from '../dragdrop/Palette';
7
- import type {
8
- Instrument,
9
- LayerDescriptor,
10
- PaletteItem,
11
- RenderContext,
12
- } from '../instrument/types';
8
+ import type { Instrument, LayerDescriptor, PaletteItem, RenderContext } from '../instrument/types';
13
9
  import { useJob } from '../job/useJob';
14
10
  import { useLabContext } from '../lab/LabContext';
15
11
  import { LayerList } from '../layers/LayerList';
@@ -25,11 +21,16 @@ import { TrialChrome } from './TrialChrome';
25
21
  /** Props for `<Trial>`. */
26
22
  export interface TrialProps {
27
23
  id: string;
24
+ /** Contributions the lab adds to this trial's chrome, merged after the
25
+ * instrument's own. */
26
+ chrome?: readonly TrialContribution[];
27
+ /** Built-in contribution ids to drop. Throws on an id that is not there. */
28
+ suppress?: readonly string[];
28
29
  }
29
30
 
30
31
  /** Renders one trial from the lab store: looks up its record and
31
32
  * instrument, and mounts the instrument inside the trial chrome. */
32
- export function Trial({ id }: TrialProps) {
33
+ export function Trial({ id, chrome, suppress }: TrialProps) {
33
34
  const lab = useLabContext();
34
35
  const storeCtx = useContext(LabStoreContext);
35
36
  if (!storeCtx) throw new Error('[labkit] <Trial> requires <LabStoreProvider>');
@@ -49,6 +50,8 @@ export function Trial({ id }: TrialProps) {
49
50
  instrument={instrument}
50
51
  store={storeCtx.store}
51
52
  isLast={lab.trials.length <= 1}
53
+ chrome={chrome}
54
+ suppress={suppress}
52
55
  />
53
56
  );
54
57
  }
@@ -58,14 +61,19 @@ interface TrialRuntimeProps {
58
61
  instrument: Instrument;
59
62
  store: LabStore;
60
63
  isLast: boolean;
64
+ chrome?: readonly TrialContribution[];
65
+ suppress?: readonly string[];
61
66
  }
62
67
 
63
- function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps) {
68
+ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: TrialRuntimeProps) {
64
69
  const canvasContainerRef = useRef<HTMLDivElement | null>(null);
65
70
  const updateTrialState = useStore(store, (s) => s.updateTrialState);
66
71
  const updateTrialConfig = useStore(store, (s) => s.updateTrialConfig);
67
72
  const updateTrialView = useStore(store, (s) => s.updateTrialView);
68
73
  const updateTrialUndoStack = useStore(store, (s) => s.updateTrialUndoStack);
74
+ const labToolId = useStore(store, (s) => s.activeToolId);
75
+ const setLabTool = useStore(store, (s) => s.setLabTool);
76
+ const setTrialTool = useStore(store, (s) => s.setTrialTool);
69
77
 
70
78
  const busRef = useRef<EventBus | null>(null);
71
79
  if (busRef.current === null) busRef.current = createEventBus();
@@ -88,6 +96,17 @@ function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps)
88
96
 
89
97
  const setView = (v: unknown): void => updateTrialView(record.id, v);
90
98
 
99
+ // A trial gets its own slot when its instrument declares tools; otherwise it
100
+ // reads the lab's. Which slot a change writes follows from the same thing.
101
+ const declaresTools = instrument.tools != null;
102
+ const resolvedToolId = declaresTools
103
+ ? (record.activeToolId ?? instrument.tools?.initial ?? instrument.tools?.tools[0]?.id ?? null)
104
+ : labToolId;
105
+ const setActiveTool = (id: string): void => {
106
+ if (declaresTools) setTrialTool(record.id, id);
107
+ else setLabTool(id);
108
+ };
109
+
91
110
  // `CanvasStack` and the zoom controls are 2D; a trial holding another view shape
92
111
  // gets the default here and simply never renders them.
93
112
  const view2d = as2DView(record.view);
@@ -127,6 +146,7 @@ function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps)
127
146
  if (!view2d) return;
128
147
  updateTrialView(record.id, { ...view2d, zoom: z });
129
148
  },
149
+ activeToolId: resolvedToolId,
130
150
  },
131
151
  emit: (event) => {
132
152
  snapshotIfNeeded(event);
@@ -175,7 +195,7 @@ function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps)
175
195
 
176
196
  const layerDescriptors: LayerDescriptor[] = useMemo(() => {
177
197
  if (!instrument.layers) return [];
178
- return instrument.layers.ids.map((lid) => ({ id: lid, label: lid }));
198
+ return instrument.layers.ids.map((l) => (typeof l === 'string' ? { id: l, label: l } : l));
179
199
  }, [instrument.layers]);
180
200
 
181
201
  const dragDropResult = useDragDrop({
@@ -228,15 +248,13 @@ function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps)
228
248
  let body: ReactNode;
229
249
  if (instrument.canvas) {
230
250
  body = (
231
- <div
232
- ref={canvasContainerRef}
233
- className="lk-trial__canvas-host"
234
- style={{ width: '100%', height: '100%', position: 'relative' }}
235
- >
251
+ <div ref={canvasContainerRef} className="lk-trial__canvas-host">
236
252
  <CanvasStack
237
253
  layers={layersWithFeedback}
238
254
  view={view2d ?? DEFAULT_VIEW}
239
255
  onViewChange={setView}
256
+ minZoom={instrument.canvas.minZoom}
257
+ maxZoom={instrument.canvas.maxZoom}
240
258
  >
241
259
  {instrument.render(renderCtx)}
242
260
  </CanvasStack>
@@ -247,26 +265,50 @@ function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps)
247
265
  body = instrument.render(renderCtx);
248
266
  }
249
267
 
250
- const paletteNode =
251
- paletteItems.length > 0 ? (
252
- <Palette items={paletteItems} onDragStart={dragDropResult.startDrag} />
253
- ) : null;
268
+ const startDrag = dragDropResult.startDrag;
269
+ const paletteNode = useMemo(
270
+ () =>
271
+ paletteItems.length > 0 ? <Palette items={paletteItems} onDragStart={startDrag} /> : null,
272
+ [paletteItems, startDrag],
273
+ );
274
+
275
+ const layerListNode = useMemo(
276
+ () =>
277
+ instrument.layers && layerDescriptors.length > 0 ? (
278
+ <LayerList
279
+ layers={layerDescriptors}
280
+ visibility={layerVisibility}
281
+ onReorder={(next) => {
282
+ setLayerOrder(next.map((l) => l.id));
283
+ bus.emit('layers.reorder');
284
+ }}
285
+ onToggle={(lid, visible) => {
286
+ setLayerVisibility((prev) => ({ ...prev, [lid]: visible }));
287
+ bus.emit('layers.toggle');
288
+ }}
289
+ />
290
+ ) : null,
291
+ [instrument.layers, layerDescriptors, layerVisibility, bus],
292
+ );
254
293
 
255
- const layerListNode =
256
- instrument.layers && layerDescriptors.length > 0 ? (
257
- <LayerList
258
- layers={layerDescriptors}
259
- visibility={layerVisibility}
260
- onReorder={(next) => {
261
- setLayerOrder(next.map((l) => l.id));
262
- bus.emit('layers.reorder');
263
- }}
264
- onToggle={(lid, visible) => {
265
- setLayerVisibility((prev) => ({ ...prev, [lid]: visible }));
266
- bus.emit('layers.toggle');
267
- }}
268
- />
269
- ) : null;
294
+ const extraChrome = useMemo<TrialContribution[]>(() => {
295
+ const out: TrialContribution[] = [];
296
+ if (paletteNode) {
297
+ out.push({
298
+ id: 'dragdrop-palette',
299
+ region: 'sidebar',
300
+ item: { title: 'Parts', body: paletteNode },
301
+ });
302
+ }
303
+ if (layerListNode) {
304
+ out.push({
305
+ id: 'layer-list',
306
+ region: 'sidebar',
307
+ item: { title: 'Layers', body: layerListNode },
308
+ });
309
+ }
310
+ return out;
311
+ }, [paletteNode, layerListNode]);
270
312
 
271
313
  return (
272
314
  <TrialChrome
@@ -276,12 +318,11 @@ function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps)
276
318
  instrument={instrument}
277
319
  isLastTrial={isLast}
278
320
  undoBindings={undoBindings}
279
- sidebarExtras={
280
- <>
281
- {paletteNode}
282
- {layerListNode}
283
- </>
284
- }
321
+ trialChrome={extraChrome}
322
+ chrome={chrome}
323
+ suppress={suppress}
324
+ activeToolId={resolvedToolId}
325
+ setActiveTool={setActiveTool}
285
326
  >
286
327
  {body}
287
328
  </TrialChrome>
@@ -1,22 +1,23 @@
1
1
  import { type KeyboardEvent, type ReactNode, useContext, useMemo } from 'react';
2
2
  import { useStore } from 'zustand/react';
3
+ import { builtinContributions } from '../chrome/builtins';
4
+ import { mergeContributions, suppressContributions } from '../chrome/merge';
5
+ import { PaletteRegion } from '../chrome/regions/PaletteRegion';
6
+ import { SidebarRegion } from '../chrome/regions/SidebarRegion';
7
+ import { StatusRegion } from '../chrome/regions/StatusRegion';
8
+ import { TitleBarRegion } from '../chrome/regions/TitleBarRegion';
9
+ import { ToolbarRegion } from '../chrome/regions/ToolbarRegion';
10
+ import { ViewportRegion } from '../chrome/regions/ViewportRegion';
11
+ import type { TrialChromeContext, TrialContribution, TrialRegion } from '../chrome/types';
12
+ import { useConfigSchema } from '../config/useConfigSchema';
3
13
  import type { Instrument } from '../instrument/types';
4
14
  import type { JobHandle } from '../job/types';
5
15
  import { useLabContext } from '../lab/LabContext';
16
+ import { JobProgress } from '../primitives/JobProgress';
6
17
  import { LabStoreContext } from '../state/context';
7
18
  import type { TrialRecord } from '../state/types';
8
19
  import { as2DView } from '../state/view';
9
- import { DefaultSidebar } from './DefaultSidebar';
10
- import { DefaultStatusBar } from './DefaultStatusBar';
11
- import { DefaultToolbar } from './DefaultToolbar';
12
- import type {
13
- SidebarSlot,
14
- StatusBarSlot,
15
- ToolbarSlot,
16
- TrialSidebarContext,
17
- TrialStatusBarContext,
18
- TrialToolbarContext,
19
- } from './slotTypes';
20
+ import { TrialTitleBar } from './TrialTitleBar';
20
21
 
21
22
  export interface UndoBindings {
22
23
  canUndo: boolean;
@@ -31,30 +32,40 @@ export interface TrialChromeProps {
31
32
  record: TrialRecord;
32
33
  instrument: Instrument;
33
34
  isLastTrial: boolean;
34
- toolbar?: ToolbarSlot;
35
- sidebar?: SidebarSlot;
36
- statusBar?: StatusBarSlot;
37
35
  undoBindings?: UndoBindings;
38
36
  /** Supplied when the instrument declares a `job`. */
39
37
  job?: JobHandle;
40
- sidebarExtras?: ReactNode;
38
+ /** Contributions the trial runtime itself adds — the drag palette and the
39
+ * layer list, which depend on runtime state the instrument cannot reach. */
40
+ trialChrome?: readonly TrialContribution[];
41
+ /** Contributions from the lab, merged last. */
42
+ chrome?: readonly TrialContribution[];
43
+ /** Built-in contribution ids to drop. Throws on an id that is not there. */
44
+ suppress?: readonly string[];
45
+ /** The trial's resolved tool slot, and the setter that writes whichever
46
+ * slot it resolved to. `Trial` owns the resolution. */
47
+ activeToolId?: string | null;
48
+ setActiveTool?: (id: string) => void;
41
49
  children: ReactNode;
42
50
  }
43
51
 
44
- /** The frame around a running instrument — toolbar, sidebar, status bar —
45
- * each replaceable by a slot. Assembles the slot contexts and wires the undo
46
- * keyboard shortcuts. */
52
+ const NO_OP = (): void => {};
53
+
54
+ /** The frame around a running instrument. Builds one chrome context, assembles
55
+ * the contributions the instrument, the runtime and the lab declare, and hands
56
+ * each region its slice. */
47
57
  export function TrialChrome({
48
58
  trialId,
49
59
  record,
50
60
  instrument,
51
61
  isLastTrial,
52
- toolbar,
53
- sidebar,
54
- statusBar,
55
62
  undoBindings,
56
63
  job,
57
- sidebarExtras,
64
+ trialChrome,
65
+ chrome,
66
+ suppress,
67
+ activeToolId = null,
68
+ setActiveTool = NO_OP,
58
69
  children,
59
70
  }: TrialChromeProps) {
60
71
  const lab = useLabContext();
@@ -68,7 +79,9 @@ export function TrialChrome({
68
79
  // and goes inert when the trial holds something else — an orbit, say.
69
80
  const view2d = as2DView(record.view);
70
81
 
71
- const toolbarCtx = useMemo<TrialToolbarContext>(() => {
82
+ const configSchema = useConfigSchema(instrument);
83
+
84
+ const ctx = useMemo<TrialChromeContext>(() => {
72
85
  const setZoom = (z: number): void => {
73
86
  if (!view2d) return;
74
87
  updateTrialView(trialId, { ...view2d, zoom: z });
@@ -76,32 +89,15 @@ export function TrialChrome({
76
89
  return {
77
90
  trialId,
78
91
  instrumentName: record.instrumentName,
79
- hasUndo: instrument.undo != null,
92
+ isLastTrial,
93
+ zoom: view2d ? view2d.zoom : null,
94
+ setZoom,
80
95
  canUndo: undoBindings?.canUndo ?? false,
81
96
  canRedo: undoBindings?.canRedo ?? false,
82
- undo: undoBindings?.undo ?? (() => {}),
83
- redo: undoBindings?.redo ?? (() => {}),
84
- zoom: view2d?.zoom ?? 1,
85
- setZoom,
86
- zoomIn: () => setZoom((view2d?.zoom ?? 1) * 1.25),
87
- zoomOut: () => setZoom((view2d?.zoom ?? 1) * 0.8),
88
- resetZoom: () => setZoom(1),
89
- hasCanvas: instrument.canvas != null,
90
- savedSnapshots: lab.savedSnapshots.filter((s) => s.trialId === trialId),
91
- saveSnapshot: (name) => lab.saveSnapshot(trialId, name),
92
- loadSnapshot: (snapshotId) => lab.loadSnapshot(trialId, snapshotId),
93
- clone: () => lab.cloneTrial(trialId),
94
- reset: () => lab.resetTrial(trialId),
95
- close: () => lab.closeTrial(trialId),
96
- isLastTrial,
97
- };
98
- }, [trialId, record, instrument, lab, isLastTrial, updateTrialView, undoBindings, view2d]);
99
-
100
- const sidebarCtx = useMemo<TrialSidebarContext>(
101
- () => ({
102
- trialId,
103
- instrumentName: record.instrumentName,
104
- configFields: instrument.configSchema?.() ?? [],
97
+ undo: undoBindings?.undo ?? NO_OP,
98
+ redo: undoBindings?.redo ?? NO_OP,
99
+ configSchema,
100
+ configFields: instrument.config ? [] : (instrument.configSchema?.() ?? []),
105
101
  config: record.config,
106
102
  setConfig: (key, value) => {
107
103
  const prevConfig = record.config as Record<string, unknown>;
@@ -117,26 +113,58 @@ export function TrialChrome({
117
113
  updateTrialState(trialId, nextState as never);
118
114
  }
119
115
  },
120
- }),
121
- [trialId, record, instrument, updateTrialConfig, updateTrialState],
116
+ savedSnapshots: lab.savedSnapshots.filter((s) => s.trialId === trialId),
117
+ saveSnapshot: (name) => lab.saveSnapshot(trialId, name),
118
+ loadSnapshot: (snapshotId) => lab.loadSnapshot(trialId, snapshotId),
119
+ clone: () => lab.cloneTrial(trialId),
120
+ reset: () => lab.resetTrial(trialId),
121
+ close: () => lab.closeTrial(trialId),
122
+ activeToolId,
123
+ setActiveTool,
124
+ };
125
+ }, [
126
+ trialId,
127
+ record,
128
+ instrument,
129
+ lab,
130
+ isLastTrial,
131
+ updateTrialView,
132
+ updateTrialConfig,
133
+ updateTrialState,
134
+ undoBindings,
135
+ view2d,
136
+ activeToolId,
137
+ setActiveTool,
138
+ configSchema,
139
+ ]);
140
+
141
+ const contributions = useMemo(
142
+ () =>
143
+ suppressContributions(
144
+ mergeContributions(
145
+ builtinContributions(instrument, ctx, lab.controls),
146
+ [...(instrument.chrome ?? [])],
147
+ [...(trialChrome ?? [])],
148
+ [...(chrome ?? [])],
149
+ ),
150
+ suppress ?? [],
151
+ ),
152
+ [instrument, ctx, trialChrome, chrome, suppress, lab.controls],
122
153
  );
123
154
 
124
- const statusCtx: TrialStatusBarContext = {
125
- trialId,
126
- instrumentName: record.instrumentName,
127
- zoom: view2d ? view2d.zoom : null,
128
- };
155
+ const inRegion = (region: TrialRegion): TrialContribution[] =>
156
+ contributions.filter((c) => c.region === region);
129
157
 
130
- const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>): void => {
158
+ const handleKeyDown = (e: KeyboardEvent<HTMLElement>): void => {
131
159
  const mod = e.metaKey || e.ctrlKey;
132
160
  if (!mod) return;
133
161
  if (e.key === 'z' || e.key === 'Z') {
134
162
  e.preventDefault();
135
- if (e.shiftKey) toolbarCtx.redo();
136
- else toolbarCtx.undo();
163
+ if (e.shiftKey) ctx.redo();
164
+ else ctx.undo();
137
165
  } else if (e.key === 's' || e.key === 'S') {
138
166
  e.preventDefault();
139
- toolbarCtx.saveSnapshot();
167
+ ctx.saveSnapshot();
140
168
  }
141
169
  };
142
170
 
@@ -144,37 +172,28 @@ export function TrialChrome({
144
172
  <section
145
173
  className="lk-trial"
146
174
  aria-label={`Trial ${record.instrumentName}`}
175
+ tabIndex={-1}
147
176
  onKeyDown={handleKeyDown}
148
177
  >
178
+ <TrialTitleBar title={record.instrumentName}>
179
+ <TitleBarRegion contributions={inRegion('titlebar')} ctx={ctx} />
180
+ </TrialTitleBar>
149
181
  <div className="lk-trial__toolbar">
150
- {toolbar ? toolbar(toolbarCtx) : <DefaultToolbar ctx={toolbarCtx} />}
182
+ <ToolbarRegion contributions={inRegion('toolbar')} ctx={ctx} />
151
183
  </div>
152
184
  <div className="lk-trial__body">
185
+ <PaletteRegion contributions={inRegion('palette')} ctx={ctx} />
153
186
  <div className="lk-trial__sidebar">
154
- {sidebar ? sidebar(sidebarCtx) : <DefaultSidebar ctx={sidebarCtx} />}
155
- {sidebarExtras}
187
+ <SidebarRegion contributions={inRegion('sidebar')} ctx={ctx} />
188
+ </div>
189
+ <div className={`lk-trial__content${instrument.canvas ? ' lk-trial__content--flush' : ''}`}>
190
+ {children}
191
+ <ViewportRegion contributions={inRegion('viewport')} ctx={ctx} />
156
192
  </div>
157
- <div className="lk-trial__content">{children}</div>
158
193
  </div>
159
194
  <div className="lk-trial__status">
160
- {job ? (
161
- <div className="lk-trial__job">
162
- <span className="lk-trial__job-count">
163
- {job.done}
164
- {job.total === null ? '' : ` / ${job.total}`}
165
- </span>
166
- {job.failures.length > 0 ? (
167
- <span className="lk-trial__job-failures">{job.failures.length} failed</span>
168
- ) : null}
169
- {job.error ? <span className="lk-trial__job-error">{job.error}</span> : null}
170
- {job.status === 'running' ? (
171
- <button type="button" className="lk-trial__job-cancel" onClick={job.cancel}>
172
- Cancel
173
- </button>
174
- ) : null}
175
- </div>
176
- ) : null}
177
- {statusBar ? statusBar(statusCtx) : <DefaultStatusBar ctx={statusCtx} />}
195
+ {job ? <JobProgress job={job} /> : null}
196
+ <StatusRegion contributions={inRegion('status')} ctx={ctx} />
178
197
  </div>
179
198
  </section>
180
199
  );