@weasel-js/labkit 1.1.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 (226) hide show
  1. package/README.md +27 -0
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
  3. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  4. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  5. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  6. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  7. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  8. package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
  9. package/dist/canvas/index.d.ts +8 -3
  10. package/dist/canvas/index.js +2 -1
  11. package/dist/chrome/index.d.ts +86 -0
  12. package/dist/chrome/index.js +8 -0
  13. package/dist/chrome/index.js.map +1 -0
  14. package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
  15. package/dist/chunk-4TMMVIDM.js.map +1 -0
  16. package/dist/chunk-HFDVGF4X.js +170 -0
  17. package/dist/chunk-HFDVGF4X.js.map +1 -0
  18. package/dist/chunk-LN6JDUGB.js +106 -0
  19. package/dist/chunk-LN6JDUGB.js.map +1 -0
  20. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  21. package/dist/chunk-NS54R52R.js.map +1 -0
  22. package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
  23. package/dist/chunk-O2BULFHX.js.map +1 -0
  24. package/dist/chunk-O7NVSCLN.js +266 -0
  25. package/dist/chunk-O7NVSCLN.js.map +1 -0
  26. package/dist/chunk-PO6L3NSH.js +366 -0
  27. package/dist/chunk-PO6L3NSH.js.map +1 -0
  28. package/dist/chunk-PQJ5B2U2.js +27 -0
  29. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  30. package/dist/chunk-RVNN4CHQ.js +212 -0
  31. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  32. package/dist/chunk-TLGRYALP.js +6151 -0
  33. package/dist/chunk-TLGRYALP.js.map +1 -0
  34. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  35. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  36. package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
  37. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  38. package/dist/chunk-ZZAYVX4X.js +505 -0
  39. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  40. package/dist/controls/index.d.ts +19 -5
  41. package/dist/controls/index.js +3 -1
  42. package/dist/dragdrop/index.d.ts +5 -2
  43. package/dist/index.d.ts +307 -107
  44. package/dist/index.js +797 -540
  45. package/dist/index.js.map +1 -1
  46. package/dist/job/index.d.ts +13 -0
  47. package/dist/job/index.js +3 -0
  48. package/dist/job/index.js.map +1 -0
  49. package/dist/layers/index.d.ts +6 -3
  50. package/dist/layers/index.js +2 -1
  51. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  52. package/dist/passthrough/weasel-canvas.js +1 -1
  53. package/dist/passthrough/weasel-ui.d.ts +3804 -3661
  54. package/dist/passthrough/weasel-ui.js +2 -2
  55. package/dist/primitives/index.d.ts +102 -8
  56. package/dist/primitives/index.js +4 -1
  57. package/dist/state/index.d.ts +3 -2
  58. package/dist/state/index.js +2 -2
  59. package/dist/styles.css +543 -144
  60. package/dist/surface/index.d.ts +77 -0
  61. package/dist/surface/index.js +4 -0
  62. package/dist/surface/index.js.map +1 -0
  63. package/dist/ui/layers/index.js +4 -3
  64. package/dist/undo/index.d.ts +5 -2
  65. package/package.json +24 -7
  66. package/src/canvas/AGENTS.md +12 -0
  67. package/src/canvas/CanvasStack.tsx +6 -2
  68. package/src/canvas/useLayerScheduler.ts +22 -15
  69. package/src/canvas/useOrbit.test.ts +71 -0
  70. package/src/canvas/useOrbit.ts +149 -0
  71. package/src/canvas/usePanZoom.test.ts +80 -0
  72. package/src/canvas/usePanZoom.ts +16 -2
  73. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  74. package/src/chrome/builtins.test.ts +153 -0
  75. package/src/chrome/builtins.tsx +205 -0
  76. package/src/chrome/index.ts +25 -0
  77. package/src/chrome/merge.test.ts +51 -0
  78. package/src/chrome/merge.ts +52 -0
  79. package/src/chrome/regions/PaletteRegion.less +14 -0
  80. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  81. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  82. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  83. package/src/chrome/regions/StatusRegion.tsx +31 -0
  84. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  85. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  87. package/src/chrome/regions/ViewportRegion.less +42 -0
  88. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  89. package/src/chrome/regions/regions.test.tsx +81 -0
  90. package/src/chrome/types.ts +127 -0
  91. package/src/config/builder.test.ts +87 -0
  92. package/src/config/builder.ts +224 -0
  93. package/src/config/fromConfigField.test.ts +93 -0
  94. package/src/config/fromConfigField.ts +74 -0
  95. package/src/config/index.ts +22 -0
  96. package/src/config/resolve.test.ts +120 -0
  97. package/src/config/resolve.ts +67 -0
  98. package/src/config/rules.ts +68 -0
  99. package/src/config/types.ts +108 -0
  100. package/src/config/useConfigSchema.ts +30 -0
  101. package/src/config/visible.ts +20 -0
  102. package/src/controls/ControlPanel.less +4 -58
  103. package/src/controls/ControlPanel.stories.tsx +86 -0
  104. package/src/controls/ControlPanel.test.tsx +223 -10
  105. package/src/controls/ControlPanel.tsx +202 -203
  106. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  107. package/src/dragdrop/DragGhost.less +1 -1
  108. package/src/dragdrop/Palette.less +1 -1
  109. package/src/index.test.ts +18 -0
  110. package/src/index.ts +38 -3
  111. package/src/instrument/SineWave.smoke.test.tsx +9 -1
  112. package/src/instrument/defineInstrument.test.ts +38 -1
  113. package/src/instrument/defineInstrument.ts +31 -4
  114. package/src/instrument/index.ts +1 -0
  115. package/src/instrument/types.ts +38 -3
  116. package/src/instrument/validateConfigSchema.test.ts +13 -3
  117. package/src/instrument/validateConfigSchema.ts +7 -10
  118. package/src/job/index.ts +3 -0
  119. package/src/job/types.ts +47 -0
  120. package/src/job/useJob.test.tsx +210 -0
  121. package/src/job/useJob.ts +134 -0
  122. package/src/lab/Lab.chrome.test.tsx +65 -0
  123. package/src/lab/Lab.stories.tsx +1 -2
  124. package/src/lab/Lab.tsx +58 -15
  125. package/src/lab/LabContext.ts +6 -0
  126. package/src/lab/LabFullChrome.stories.tsx +308 -0
  127. package/src/lab/LabHeader.test.tsx +42 -0
  128. package/src/lab/LabHeader.tsx +57 -0
  129. package/src/lab/LabPalette.test.tsx +35 -0
  130. package/src/lab/LabPalette.tsx +34 -0
  131. package/src/lab/LabShell.less +50 -3
  132. package/src/lab/Workspace.less +1 -30
  133. package/src/lab/Workspace.surface.test.tsx +49 -0
  134. package/src/lab/Workspace.test.tsx +20 -10
  135. package/src/lab/Workspace.tsx +19 -9
  136. package/src/lab/index.ts +2 -0
  137. package/src/layers/AGENTS.md +1 -1
  138. package/src/layers/LayerList.less +31 -7
  139. package/src/layers/LayerList.tsx +17 -4
  140. package/src/passthrough/weasel-ui.ts +23 -0
  141. package/src/primitives/DragHandleGlyph.tsx +29 -0
  142. package/src/primitives/FloatingPanel.less +28 -0
  143. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  144. package/src/primitives/FloatingPanel.test.tsx +240 -0
  145. package/src/primitives/FloatingPanel.tsx +190 -0
  146. package/src/primitives/FpsMeter.test.tsx +44 -0
  147. package/src/primitives/FpsMeter.tsx +20 -9
  148. package/src/primitives/JobProgress.less +69 -0
  149. package/src/primitives/JobProgress.stories.tsx +48 -0
  150. package/src/primitives/JobProgress.tsx +65 -0
  151. package/src/primitives/Legend.less +47 -0
  152. package/src/primitives/Legend.stories.tsx +32 -0
  153. package/src/primitives/Legend.test.tsx +57 -0
  154. package/src/primitives/Legend.tsx +40 -0
  155. package/src/primitives/Sidebar.less +32 -2
  156. package/src/primitives/StatusBar.less +7 -0
  157. package/src/primitives/StatusBar.tsx +10 -3
  158. package/src/primitives/Toolbar.less +50 -3
  159. package/src/primitives/Toolbar.test.tsx +67 -1
  160. package/src/primitives/Toolbar.tsx +72 -6
  161. package/src/primitives/ZoomControl.less +31 -0
  162. package/src/primitives/ZoomControl.tsx +76 -0
  163. package/src/primitives/floating.entry.test.tsx +14 -0
  164. package/src/primitives/index.ts +10 -2
  165. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  166. package/src/primitives/useRovingTabIndex.ts +79 -0
  167. package/src/state/store.ts +16 -2
  168. package/src/state/toolSlot.test.ts +36 -0
  169. package/src/state/types.ts +8 -2
  170. package/src/state/view.test.ts +127 -0
  171. package/src/state/view.ts +18 -0
  172. package/src/styles.less +6 -0
  173. package/src/surface/AGENTS.md +64 -0
  174. package/src/surface/SurfaceContext.ts +5 -0
  175. package/src/surface/composeRects.test.ts +50 -0
  176. package/src/surface/composeRects.ts +19 -0
  177. package/src/surface/deviceRect.test.ts +40 -0
  178. package/src/surface/deviceRect.ts +19 -0
  179. package/src/surface/index.ts +7 -0
  180. package/src/surface/rect.ts +16 -0
  181. package/src/surface/useSurfaceTile.test.tsx +67 -0
  182. package/src/surface/useSurfaceTile.ts +32 -0
  183. package/src/surface/useTiledSurface.test.tsx +231 -0
  184. package/src/surface/useTiledSurface.ts +161 -0
  185. package/src/test-setup.ts +26 -0
  186. package/src/theme/base.less +19 -2
  187. package/src/theme/interstellar.tokens.json +6 -2
  188. package/src/tools/types.ts +24 -0
  189. package/src/trial/Trial.config.test.tsx +113 -0
  190. package/src/trial/Trial.job.test.tsx +73 -0
  191. package/src/trial/Trial.less +121 -4
  192. package/src/trial/Trial.stories.tsx +9 -1
  193. package/src/trial/Trial.test.tsx +67 -96
  194. package/src/trial/Trial.tsx +112 -45
  195. package/src/trial/TrialChrome.tsx +110 -64
  196. package/src/trial/TrialDragContext.ts +10 -0
  197. package/src/trial/TrialTitleBar.tsx +41 -0
  198. package/src/trial/index.ts +1 -15
  199. package/src/trial/trialChrome.borders.test.ts +38 -0
  200. package/src/ui/layers/LayerStack.less +9 -9
  201. package/src/ui/layers/LayerStack.tsx +1 -13
  202. package/src/ui/properties/CurveField.less +2 -2
  203. package/src/ui/properties/CurveField.test.tsx +11 -11
  204. package/src/ui/properties/PropertyGroup.less +2 -2
  205. package/src/ui/properties/PropertyPanel.less +55 -41
  206. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  207. package/src/ui/properties/storyLayouts.tsx +2 -2
  208. package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
  209. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  210. package/dist/chunk-3TYUJR7Z.js.map +0 -1
  211. package/dist/chunk-574LJAV4.js.map +0 -1
  212. package/dist/chunk-5R2ATYPJ.js +0 -201
  213. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  214. package/dist/chunk-C6GJKPUI.js +0 -5986
  215. package/dist/chunk-C6GJKPUI.js.map +0 -1
  216. package/dist/chunk-DJLDIRFN.js.map +0 -1
  217. package/dist/chunk-NRKWVTVT.js.map +0 -1
  218. package/dist/chunk-PMAU3SEE.js +0 -126
  219. package/dist/chunk-PMAU3SEE.js.map +0 -1
  220. package/dist/chunk-SFL7NFKN.js.map +0 -1
  221. package/src/instrument/capabilityDetector.test.ts +0 -64
  222. package/src/instrument/capabilityDetector.ts +0 -20
  223. package/src/trial/DefaultSidebar.tsx +0 -29
  224. package/src/trial/DefaultStatusBar.tsx +0 -19
  225. package/src/trial/DefaultToolbar.tsx +0 -92
  226. package/src/trial/slotTypes.ts +0 -53
@@ -2,20 +2,17 @@ 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
- ViewTransform,
13
- } from '../instrument/types';
8
+ import type { Instrument, LayerDescriptor, PaletteItem, RenderContext } from '../instrument/types';
9
+ import { useJob } from '../job/useJob';
14
10
  import { useLabContext } from '../lab/LabContext';
15
11
  import { LayerList } from '../layers/LayerList';
16
12
  import { LabStoreContext } from '../state/context';
17
13
  import type { LabStore } from '../state/store';
18
14
  import type { TrialRecord } from '../state/types';
15
+ import { as2DView, DEFAULT_VIEW } from '../state/view';
19
16
  import { createEventBus, type EventBus } from '../undo/eventBus';
20
17
  import { pushSnapshot, redo as undoRedo, undo as undoUndo } from '../undo/undoStack';
21
18
  import type { UndoBindings } from './TrialChrome';
@@ -24,11 +21,16 @@ import { TrialChrome } from './TrialChrome';
24
21
  /** Props for `<Trial>`. */
25
22
  export interface TrialProps {
26
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[];
27
29
  }
28
30
 
29
31
  /** Renders one trial from the lab store: looks up its record and
30
32
  * instrument, and mounts the instrument inside the trial chrome. */
31
- export function Trial({ id }: TrialProps) {
33
+ export function Trial({ id, chrome, suppress }: TrialProps) {
32
34
  const lab = useLabContext();
33
35
  const storeCtx = useContext(LabStoreContext);
34
36
  if (!storeCtx) throw new Error('[labkit] <Trial> requires <LabStoreProvider>');
@@ -48,6 +50,8 @@ export function Trial({ id }: TrialProps) {
48
50
  instrument={instrument}
49
51
  store={storeCtx.store}
50
52
  isLast={lab.trials.length <= 1}
53
+ chrome={chrome}
54
+ suppress={suppress}
51
55
  />
52
56
  );
53
57
  }
@@ -57,14 +61,19 @@ interface TrialRuntimeProps {
57
61
  instrument: Instrument;
58
62
  store: LabStore;
59
63
  isLast: boolean;
64
+ chrome?: readonly TrialContribution[];
65
+ suppress?: readonly string[];
60
66
  }
61
67
 
62
- function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps) {
68
+ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: TrialRuntimeProps) {
63
69
  const canvasContainerRef = useRef<HTMLDivElement | null>(null);
64
70
  const updateTrialState = useStore(store, (s) => s.updateTrialState);
65
71
  const updateTrialConfig = useStore(store, (s) => s.updateTrialConfig);
66
72
  const updateTrialView = useStore(store, (s) => s.updateTrialView);
67
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);
68
77
 
69
78
  const busRef = useRef<EventBus | null>(null);
70
79
  if (busRef.current === null) busRef.current = createEventBus();
@@ -85,7 +94,32 @@ function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps)
85
94
  updateTrialUndoStack(record.id, (prev) => pushSnapshot(prev, snap, maxDepth));
86
95
  };
87
96
 
88
- const setView = (v: ViewTransform): void => updateTrialView(record.id, v);
97
+ const setView = (v: unknown): void => updateTrialView(record.id, v);
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
+
110
+ // `CanvasStack` and the zoom controls are 2D; a trial holding another view shape
111
+ // gets the default here and simply never renders them.
112
+ const view2d = as2DView(record.view);
113
+
114
+ const jobCap = instrument.job;
115
+ // Hooks cannot be conditional, so a trial without the capability still calls
116
+ // this — with a runner that yields nothing and is never started.
117
+ const job = useJob({
118
+ capability: jobCap ?? { run: async function* () {}, onItem: (_i, st) => st },
119
+ config: record.config,
120
+ state: record.state,
121
+ setState: (next) => updateTrialState(record.id, next as never),
122
+ });
89
123
 
90
124
  const renderCtx: RenderContext<unknown, unknown> = {
91
125
  state: record.state,
@@ -105,13 +139,20 @@ function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps)
105
139
  },
106
140
  trial: {
107
141
  id: record.id,
108
- zoom: record.view.zoom,
109
- setZoom: (z) => updateTrialView(record.id, { ...record.view, zoom: z }),
142
+ view: record.view,
143
+ setView,
144
+ zoom: view2d?.zoom ?? 1,
145
+ setZoom: (z) => {
146
+ if (!view2d) return;
147
+ updateTrialView(record.id, { ...view2d, zoom: z });
148
+ },
149
+ activeToolId: resolvedToolId,
110
150
  },
111
151
  emit: (event) => {
112
152
  snapshotIfNeeded(event);
113
153
  bus.emit(event);
114
154
  },
155
+ job: jobCap ? job : undefined,
115
156
  };
116
157
 
117
158
  const undoBindings: UndoBindings | undefined = undoCap
@@ -154,13 +195,13 @@ function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps)
154
195
 
155
196
  const layerDescriptors: LayerDescriptor[] = useMemo(() => {
156
197
  if (!instrument.layers) return [];
157
- 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));
158
199
  }, [instrument.layers]);
159
200
 
160
201
  const dragDropResult = useDragDrop({
161
202
  capability: instrument.dragDrop ?? { palette: [], onDrop: (_p, _i, s) => s },
162
203
  canvasContainerRef,
163
- view: record.view,
204
+ view: view2d ?? DEFAULT_VIEW,
164
205
  state: record.state,
165
206
  config: record.config,
166
207
  setState: (next) => {
@@ -207,12 +248,14 @@ function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps)
207
248
  let body: ReactNode;
208
249
  if (instrument.canvas) {
209
250
  body = (
210
- <div
211
- ref={canvasContainerRef}
212
- className="lk-trial__canvas-host"
213
- style={{ width: '100%', height: '100%', position: 'relative' }}
214
- >
215
- <CanvasStack layers={layersWithFeedback} view={record.view} onViewChange={setView}>
251
+ <div ref={canvasContainerRef} className="lk-trial__canvas-host">
252
+ <CanvasStack
253
+ layers={layersWithFeedback}
254
+ view={view2d ?? DEFAULT_VIEW}
255
+ onViewChange={setView}
256
+ minZoom={instrument.canvas.minZoom}
257
+ maxZoom={instrument.canvas.maxZoom}
258
+ >
216
259
  {instrument.render(renderCtx)}
217
260
  </CanvasStack>
218
261
  <DragOverlay drag={dragDropResult.drag} />
@@ -222,40 +265,64 @@ function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps)
222
265
  body = instrument.render(renderCtx);
223
266
  }
224
267
 
225
- const paletteNode =
226
- paletteItems.length > 0 ? (
227
- <Palette items={paletteItems} onDragStart={dragDropResult.startDrag} />
228
- ) : 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
+ );
229
293
 
230
- const layerListNode =
231
- instrument.layers && layerDescriptors.length > 0 ? (
232
- <LayerList
233
- layers={layerDescriptors}
234
- visibility={layerVisibility}
235
- onReorder={(next) => {
236
- setLayerOrder(next.map((l) => l.id));
237
- bus.emit('layers.reorder');
238
- }}
239
- onToggle={(lid, visible) => {
240
- setLayerVisibility((prev) => ({ ...prev, [lid]: visible }));
241
- bus.emit('layers.toggle');
242
- }}
243
- />
244
- ) : 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]);
245
312
 
246
313
  return (
247
314
  <TrialChrome
315
+ job={jobCap ? job : undefined}
248
316
  trialId={record.id}
249
317
  record={record}
250
318
  instrument={instrument}
251
319
  isLastTrial={isLast}
252
320
  undoBindings={undoBindings}
253
- sidebarExtras={
254
- <>
255
- {paletteNode}
256
- {layerListNode}
257
- </>
258
- }
321
+ trialChrome={extraChrome}
322
+ chrome={chrome}
323
+ suppress={suppress}
324
+ activeToolId={resolvedToolId}
325
+ setActiveTool={setActiveTool}
259
326
  >
260
327
  {body}
261
328
  </TrialChrome>
@@ -1,20 +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';
14
+ import type { JobHandle } from '../job/types';
4
15
  import { useLabContext } from '../lab/LabContext';
16
+ import { JobProgress } from '../primitives/JobProgress';
5
17
  import { LabStoreContext } from '../state/context';
6
18
  import type { TrialRecord } from '../state/types';
7
- import { DefaultSidebar } from './DefaultSidebar';
8
- import { DefaultStatusBar } from './DefaultStatusBar';
9
- import { DefaultToolbar } from './DefaultToolbar';
10
- import type {
11
- SidebarSlot,
12
- StatusBarSlot,
13
- ToolbarSlot,
14
- TrialSidebarContext,
15
- TrialStatusBarContext,
16
- TrialToolbarContext,
17
- } from './slotTypes';
19
+ import { as2DView } from '../state/view';
20
+ import { TrialTitleBar } from './TrialTitleBar';
18
21
 
19
22
  export interface UndoBindings {
20
23
  canUndo: boolean;
@@ -29,27 +32,40 @@ export interface TrialChromeProps {
29
32
  record: TrialRecord;
30
33
  instrument: Instrument;
31
34
  isLastTrial: boolean;
32
- toolbar?: ToolbarSlot;
33
- sidebar?: SidebarSlot;
34
- statusBar?: StatusBarSlot;
35
35
  undoBindings?: UndoBindings;
36
- sidebarExtras?: ReactNode;
36
+ /** Supplied when the instrument declares a `job`. */
37
+ job?: JobHandle;
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;
37
49
  children: ReactNode;
38
50
  }
39
51
 
40
- /** The frame around a running instrument — toolbar, sidebar, status bar —
41
- * each replaceable by a slot. Assembles the slot contexts and wires the undo
42
- * 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. */
43
57
  export function TrialChrome({
44
58
  trialId,
45
59
  record,
46
60
  instrument,
47
61
  isLastTrial,
48
- toolbar,
49
- sidebar,
50
- statusBar,
51
62
  undoBindings,
52
- sidebarExtras,
63
+ job,
64
+ trialChrome,
65
+ chrome,
66
+ suppress,
67
+ activeToolId = null,
68
+ setActiveTool = NO_OP,
53
69
  children,
54
70
  }: TrialChromeProps) {
55
71
  const lab = useLabContext();
@@ -59,39 +75,29 @@ export function TrialChrome({
59
75
  const updateTrialConfig = useStore(storeCtx.store, (s) => s.updateTrialConfig);
60
76
  const updateTrialState = useStore(storeCtx.store, (s) => s.updateTrialState);
61
77
 
62
- const toolbarCtx = useMemo<TrialToolbarContext>(() => {
78
+ // The trial view is opaque to labkit, so the zoom chrome asks for the 2D shape
79
+ // and goes inert when the trial holds something else — an orbit, say.
80
+ const view2d = as2DView(record.view);
81
+
82
+ const configSchema = useConfigSchema(instrument);
83
+
84
+ const ctx = useMemo<TrialChromeContext>(() => {
63
85
  const setZoom = (z: number): void => {
64
- updateTrialView(trialId, { ...record.view, zoom: z });
86
+ if (!view2d) return;
87
+ updateTrialView(trialId, { ...view2d, zoom: z });
65
88
  };
66
89
  return {
67
90
  trialId,
68
91
  instrumentName: record.instrumentName,
69
- hasUndo: instrument.undo != null,
92
+ isLastTrial,
93
+ zoom: view2d ? view2d.zoom : null,
94
+ setZoom,
70
95
  canUndo: undoBindings?.canUndo ?? false,
71
96
  canRedo: undoBindings?.canRedo ?? false,
72
- undo: undoBindings?.undo ?? (() => {}),
73
- redo: undoBindings?.redo ?? (() => {}),
74
- zoom: record.view.zoom,
75
- setZoom,
76
- zoomIn: () => setZoom(record.view.zoom * 1.25),
77
- zoomOut: () => setZoom(record.view.zoom * 0.8),
78
- resetZoom: () => setZoom(1),
79
- hasCanvas: instrument.canvas != null,
80
- savedSnapshots: lab.savedSnapshots.filter((s) => s.trialId === trialId),
81
- saveSnapshot: (name) => lab.saveSnapshot(trialId, name),
82
- loadSnapshot: (snapshotId) => lab.loadSnapshot(trialId, snapshotId),
83
- clone: () => lab.cloneTrial(trialId),
84
- reset: () => lab.resetTrial(trialId),
85
- close: () => lab.closeTrial(trialId),
86
- isLastTrial,
87
- };
88
- }, [trialId, record, instrument, lab, isLastTrial, updateTrialView, undoBindings]);
89
-
90
- const sidebarCtx = useMemo<TrialSidebarContext>(
91
- () => ({
92
- trialId,
93
- instrumentName: record.instrumentName,
94
- configFields: instrument.configSchema?.() ?? [],
97
+ undo: undoBindings?.undo ?? NO_OP,
98
+ redo: undoBindings?.redo ?? NO_OP,
99
+ configSchema,
100
+ configFields: instrument.config ? [] : (instrument.configSchema?.() ?? []),
95
101
  config: record.config,
96
102
  setConfig: (key, value) => {
97
103
  const prevConfig = record.config as Record<string, unknown>;
@@ -107,26 +113,58 @@ export function TrialChrome({
107
113
  updateTrialState(trialId, nextState as never);
108
114
  }
109
115
  },
110
- }),
111
- [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],
112
153
  );
113
154
 
114
- const statusCtx: TrialStatusBarContext = {
115
- trialId,
116
- instrumentName: record.instrumentName,
117
- zoom: record.view.zoom,
118
- };
155
+ const inRegion = (region: TrialRegion): TrialContribution[] =>
156
+ contributions.filter((c) => c.region === region);
119
157
 
120
- const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>): void => {
158
+ const handleKeyDown = (e: KeyboardEvent<HTMLElement>): void => {
121
159
  const mod = e.metaKey || e.ctrlKey;
122
160
  if (!mod) return;
123
161
  if (e.key === 'z' || e.key === 'Z') {
124
162
  e.preventDefault();
125
- if (e.shiftKey) toolbarCtx.redo();
126
- else toolbarCtx.undo();
163
+ if (e.shiftKey) ctx.redo();
164
+ else ctx.undo();
127
165
  } else if (e.key === 's' || e.key === 'S') {
128
166
  e.preventDefault();
129
- toolbarCtx.saveSnapshot();
167
+ ctx.saveSnapshot();
130
168
  }
131
169
  };
132
170
 
@@ -134,20 +172,28 @@ export function TrialChrome({
134
172
  <section
135
173
  className="lk-trial"
136
174
  aria-label={`Trial ${record.instrumentName}`}
175
+ tabIndex={-1}
137
176
  onKeyDown={handleKeyDown}
138
177
  >
178
+ <TrialTitleBar title={record.instrumentName}>
179
+ <TitleBarRegion contributions={inRegion('titlebar')} ctx={ctx} />
180
+ </TrialTitleBar>
139
181
  <div className="lk-trial__toolbar">
140
- {toolbar ? toolbar(toolbarCtx) : <DefaultToolbar ctx={toolbarCtx} />}
182
+ <ToolbarRegion contributions={inRegion('toolbar')} ctx={ctx} />
141
183
  </div>
142
184
  <div className="lk-trial__body">
185
+ <PaletteRegion contributions={inRegion('palette')} ctx={ctx} />
143
186
  <div className="lk-trial__sidebar">
144
- {sidebar ? sidebar(sidebarCtx) : <DefaultSidebar ctx={sidebarCtx} />}
145
- {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} />
146
192
  </div>
147
- <div className="lk-trial__content">{children}</div>
148
193
  </div>
149
194
  <div className="lk-trial__status">
150
- {statusBar ? statusBar(statusCtx) : <DefaultStatusBar ctx={statusCtx} />}
195
+ {job ? <JobProgress job={job} /> : null}
196
+ <StatusRegion contributions={inRegion('status')} ctx={ctx} />
151
197
  </div>
152
198
  </section>
153
199
  );
@@ -0,0 +1,10 @@
1
+ import { createContext, useContext } from 'react';
2
+ import type { NodeId } from 'windease';
3
+
4
+ /** Supplied by `<Workspace>` when trials are reorderable, so a trial's title
5
+ * bar can be the drag surface. Null when reordering is off. */
6
+ export const TrialDragContext = createContext<{ nodeId: NodeId } | null>(null);
7
+
8
+ export function useTrialDrag(): { nodeId: NodeId } | null {
9
+ return useContext(TrialDragContext);
10
+ }
@@ -0,0 +1,41 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { NodeId } from 'windease';
3
+ import { useDragHandle } from 'windease/react';
4
+ import { useTrialDrag } from './TrialDragContext';
5
+
6
+ /** Props for `<TrialTitleBar>`. */
7
+ export interface TrialTitleBarProps {
8
+ title: string;
9
+ children?: ReactNode;
10
+ }
11
+
12
+ // `useDragHandle` needs a node id and windease's DragProvider, so the draggable
13
+ // form is a separate component rather than a conditional hook.
14
+ function Draggable({ nodeId, title, children }: TrialTitleBarProps & { nodeId: NodeId }) {
15
+ const handlers = useDragHandle(nodeId);
16
+ return (
17
+ <div className="lk-trial__titlebar lk-trial__titlebar--draggable" {...handlers}>
18
+ <span className="lk-trial__title">{title}</span>
19
+ {children}
20
+ </div>
21
+ );
22
+ }
23
+
24
+ /** A trial's title bar. When the workspace allows reordering the whole bar is
25
+ * the drag surface — there is no separate grip, because a window's title bar
26
+ * is already the thing you expect to drag. */
27
+ export function TrialTitleBar({ title, children }: TrialTitleBarProps) {
28
+ const drag = useTrialDrag();
29
+ if (drag)
30
+ return (
31
+ <Draggable nodeId={drag.nodeId} title={title}>
32
+ {children}
33
+ </Draggable>
34
+ );
35
+ return (
36
+ <div className="lk-trial__titlebar">
37
+ <span className="lk-trial__title">{title}</span>
38
+ {children}
39
+ </div>
40
+ );
41
+ }
@@ -1,20 +1,6 @@
1
- export type { DefaultSidebarProps } from './DefaultSidebar';
2
- export { DefaultSidebar } from './DefaultSidebar';
3
- export type { DefaultStatusBarProps } from './DefaultStatusBar';
4
- export { DefaultStatusBar } from './DefaultStatusBar';
5
- export type { DefaultToolbarProps } from './DefaultToolbar';
6
- export { DefaultToolbar } from './DefaultToolbar';
7
- export type {
8
- SidebarSlot,
9
- StatusBarSlot,
10
- ToolbarSlot,
11
- TrialSidebarContext,
12
- TrialStatusBarContext,
13
- TrialToolbarContext,
14
- } from './slotTypes';
15
1
  export type { TrialProps } from './Trial';
16
2
  export { Trial } from './Trial';
17
- export type { TrialChromeProps } from './TrialChrome';
3
+ export type { TrialChromeProps, UndoBindings } from './TrialChrome';
18
4
  export { TrialChrome } from './TrialChrome';
19
5
  export {
20
6
  addTrial,
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ const less = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'Trial.less'), 'utf8');
7
+
8
+ /** The body of one rule, by selector. Resolves Less's `&__suffix` nesting and
9
+ * matches braces, so a nested block cannot end the slice early. */
10
+ function rule(selector: string): string {
11
+ const nested = selector.replace(/^\.[a-z-]+?(__|--)/, '&$1');
12
+ const start = [selector, nested].map((s) => less.indexOf(`${s} {`)).find((i) => i !== -1);
13
+ if (start === undefined) throw new Error(`no rule for ${selector}`);
14
+ const open = less.indexOf('{', start);
15
+ let depth = 0;
16
+ for (let i = open; i < less.length; i += 1) {
17
+ if (less[i] === '{') depth += 1;
18
+ else if (less[i] === '}') {
19
+ depth -= 1;
20
+ if (depth === 0) return less.slice(open + 1, i);
21
+ }
22
+ }
23
+ throw new Error(`unterminated rule for ${selector}`);
24
+ }
25
+
26
+ describe('trial chrome seams', () => {
27
+ // A `not.toMatch` passes on an empty string, so pin that `rule()` found the
28
+ // rule it was asked for.
29
+ it('isolates the rule it is asked for', () => {
30
+ expect(rule('.lk-trial__toolbar')).toMatch(/flex-shrink/);
31
+ expect(rule('.lk-trial__status')).toMatch(/flex-shrink/);
32
+ });
33
+
34
+ it('lets the component draw its own border, not the wrapper', () => {
35
+ expect(rule('.lk-trial__toolbar')).not.toMatch(/border-bottom:\s*[^;]*solid/);
36
+ expect(rule('.lk-trial__status')).not.toMatch(/border-top:\s*[^;]*solid/);
37
+ });
38
+ });