@weasel-js/labkit 1.4.4 → 1.5.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 (278) hide show
  1. package/README.md +54 -6
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
  7. package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
  8. package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
  9. package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
  10. package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
  11. package/dist/canvas/index.d.ts +18 -129
  12. package/dist/canvas/index.js +4 -2
  13. package/dist/chrome/index.d.ts +5 -93
  14. package/dist/chrome/index.js +10 -6
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  26. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  27. package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
  28. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  29. package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
  30. package/dist/chunk-IJJUK4FU.js.map +1 -0
  31. package/dist/chunk-IXTQI7JX.js +737 -0
  32. package/dist/chunk-IXTQI7JX.js.map +1 -0
  33. package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
  34. package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
  35. package/dist/chunk-PHK4W562.js +72 -0
  36. package/dist/chunk-PHK4W562.js.map +1 -0
  37. package/dist/chunk-PJ7WEQBJ.js +18 -0
  38. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  39. package/dist/chunk-RWNDDKOH.js +682 -0
  40. package/dist/chunk-RWNDDKOH.js.map +1 -0
  41. package/dist/chunk-T3EC2DXD.js +12628 -0
  42. package/dist/chunk-T3EC2DXD.js.map +1 -0
  43. package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
  44. package/dist/chunk-UN4U4P63.js.map +1 -0
  45. package/dist/chunk-WIG6XHJ7.js +35 -0
  46. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  47. package/dist/chunk-Z76KEZAX.js +15 -0
  48. package/dist/chunk-Z76KEZAX.js.map +1 -0
  49. package/dist/chunk-ZDHY47EV.js +363 -0
  50. package/dist/chunk-ZDHY47EV.js.map +1 -0
  51. package/dist/config/index.d.ts +276 -0
  52. package/dist/config/index.js +8 -0
  53. package/dist/config/index.js.map +1 -0
  54. package/dist/controls/index.d.ts +3 -4
  55. package/dist/controls/index.js +4 -3
  56. package/dist/dragdrop/index.d.ts +3 -5
  57. package/dist/index.d.ts +266 -413
  58. package/dist/index.js +1775 -2251
  59. package/dist/index.js.map +1 -1
  60. package/dist/layers/index.d.ts +4 -6
  61. package/dist/layers/index.js +2 -2
  62. package/dist/loupe/index.d.ts +69 -20
  63. package/dist/loupe/index.js +2 -2
  64. package/dist/passthrough/weasel-ui.d.ts +175 -309
  65. package/dist/passthrough/weasel-ui.js +1 -1
  66. package/dist/primitives/index.d.ts +55 -6
  67. package/dist/primitives/index.js +6 -4
  68. package/dist/state/index.d.ts +13 -8
  69. package/dist/state/index.js +18 -13
  70. package/dist/state/index.js.map +1 -1
  71. package/dist/styles.css +223 -76
  72. package/dist/surface/index.d.ts +44 -7
  73. package/dist/surface/index.js +2 -1
  74. package/dist/ui/layers/index.js +1 -1
  75. package/dist/undo/index.d.ts +3 -5
  76. package/package.json +15 -8
  77. package/src/annotations/AnnotationOverlay.tsx +56 -5
  78. package/src/annotations/Annotations.less +4 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +105 -6
  80. package/src/annotations/drawOne.test.ts +1 -1
  81. package/src/annotations/drawOne.ts +2 -2
  82. package/src/annotations/paint.test.ts +2 -2
  83. package/src/annotations/paint.ts +1 -1
  84. package/src/annotations/preload.ts +6 -0
  85. package/src/annotations/store.test.ts +18 -0
  86. package/src/annotations/store.ts +5 -4
  87. package/src/annotations/svgNodes.test.ts +2 -2
  88. package/src/annotations/svgNodes.ts +1 -0
  89. package/src/annotations/toolMap.test.ts +9 -5
  90. package/src/annotations/toolMap.ts +6 -3
  91. package/src/annotations/types.ts +6 -3
  92. package/src/canvas/CameraWheelContext.ts +12 -0
  93. package/src/canvas/CanvasStack.stories.tsx +1 -1
  94. package/src/canvas/CanvasStack.test.tsx +25 -0
  95. package/src/canvas/CanvasStack.tsx +31 -4
  96. package/src/canvas/Stage.less +18 -0
  97. package/src/canvas/Stage.test.tsx +186 -0
  98. package/src/canvas/Stage.tsx +134 -0
  99. package/src/canvas/camera.test.ts +17 -2
  100. package/src/canvas/camera.ts +9 -9
  101. package/src/canvas/index.ts +4 -0
  102. package/src/canvas/usePanZoom.test.ts +28 -0
  103. package/src/canvas/usePanZoom.ts +21 -9
  104. package/src/chrome/ChromeRegions.stories.tsx +1 -4
  105. package/src/chrome/LabChrome.tsx +130 -0
  106. package/src/chrome/builtins.test.ts +1 -0
  107. package/src/chrome/builtins.tsx +21 -31
  108. package/src/chrome/index.ts +16 -0
  109. package/src/chrome/labRegions.test.tsx +208 -0
  110. package/src/chrome/labTypes.ts +50 -0
  111. package/src/chrome/merge.ts +7 -9
  112. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  113. package/src/chrome/regions/PaletteRegion.tsx +16 -9
  114. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  115. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  116. package/src/chrome/regions/StatusRegion.tsx +15 -8
  117. package/src/chrome/regions/ToolbarRegion.tsx +34 -16
  118. package/src/chrome/types.ts +50 -20
  119. package/src/config/auto.test.ts +12 -0
  120. package/src/config/auto.ts +15 -0
  121. package/src/config/autoConfig.test.ts +107 -0
  122. package/src/config/autoConfig.ts +153 -0
  123. package/src/config/builder.test.ts +42 -0
  124. package/src/config/builder.ts +68 -17
  125. package/src/config/declarationEmit.fixture.ts +11 -0
  126. package/src/config/declarationEmit.test.ts +51 -0
  127. package/src/config/entry.test.ts +46 -0
  128. package/src/config/index.ts +16 -1
  129. package/src/config/nodeClasses.test.ts +18 -0
  130. package/src/config/path.ts +7 -2
  131. package/src/config/resolve.test.ts +25 -0
  132. package/src/config/resolve.ts +17 -1
  133. package/src/config/types.ts +34 -2
  134. package/src/config/useResolvedConfig.ts +26 -0
  135. package/src/controls/AutoControls.stories.tsx +88 -0
  136. package/src/controls/ControlPanel.stories.tsx +42 -1
  137. package/src/controls/ControlPanel.test.tsx +530 -4
  138. package/src/controls/ControlPanel.tsx +562 -82
  139. package/src/fake-indexeddb-auto.d.ts +5 -0
  140. package/src/index.test.ts +7 -0
  141. package/src/index.ts +66 -5
  142. package/src/instrument/serializers.test.ts +22 -0
  143. package/src/instrument/serializers.ts +40 -1
  144. package/src/instrument/types.ts +25 -1
  145. package/src/lab/Lab.chrome.test.tsx +16 -0
  146. package/src/lab/Lab.less +4 -13
  147. package/src/lab/Lab.persist.test.tsx +147 -0
  148. package/src/lab/Lab.stories.tsx +1 -3
  149. package/src/lab/Lab.surface.test.tsx +71 -3
  150. package/src/lab/Lab.test.tsx +168 -16
  151. package/src/lab/Lab.tsx +434 -129
  152. package/src/lab/LabContext.ts +8 -0
  153. package/src/lab/LabFit.stories.less +18 -0
  154. package/src/lab/LabFit.stories.tsx +219 -0
  155. package/src/lab/LabFullChrome.stories.tsx +1 -4
  156. package/src/lab/LabHeader.test.tsx +40 -9
  157. package/src/lab/LabHeader.tsx +7 -14
  158. package/src/lab/LabPalette.tsx +13 -26
  159. package/src/lab/LabRoot.test.tsx +56 -0
  160. package/src/lab/LabRoot.tsx +47 -0
  161. package/src/lab/LabShell.less +73 -15
  162. package/src/lab/LabShell.stories.tsx +50 -1
  163. package/src/lab/LabShell.tsx +18 -27
  164. package/src/lab/LabSwitcher.less +18 -11
  165. package/src/lab/LabSwitcher.test.tsx +14 -9
  166. package/src/lab/LabSwitcher.tsx +8 -4
  167. package/src/lab/Workspace.less +1 -1
  168. package/src/lab/Workspace.stories.tsx +1 -1
  169. package/src/lab/Workspace.surface.test.tsx +1 -0
  170. package/src/lab/fitCheck.test.ts +57 -0
  171. package/src/lab/fitCheck.ts +125 -0
  172. package/src/lab/index.ts +2 -0
  173. package/src/layers/LayerList.stories.tsx +1 -1
  174. package/src/loupe/AGENTS.md +17 -5
  175. package/src/loupe/Loupe.less +1 -1
  176. package/src/loupe/LoupeGestures.tsx +67 -0
  177. package/src/loupe/TrialLoupe.tsx +43 -28
  178. package/src/loupe/canvasLens.ts +1 -1
  179. package/src/loupe/index.ts +4 -0
  180. package/src/loupe/loupeActions.ts +98 -0
  181. package/src/loupe/types.ts +1 -1
  182. package/src/loupe/useLoupe.test.tsx +60 -3
  183. package/src/loupe/useLoupe.ts +29 -49
  184. package/src/passthrough/weasel-ui.ts +9 -0
  185. package/src/primitives/FloatingPanel.stories.tsx +14 -2
  186. package/src/primitives/FloatingPanel.test.tsx +47 -16
  187. package/src/primitives/FloatingPanel.tsx +11 -25
  188. package/src/primitives/FpsMeter.stories.tsx +1 -1
  189. package/src/primitives/JobProgress.stories.tsx +1 -1
  190. package/src/primitives/Legend.stories.tsx +1 -1
  191. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  192. package/src/primitives/Sidebar.less +7 -0
  193. package/src/primitives/Sidebar.stories.tsx +1 -1
  194. package/src/primitives/Split.less +14 -0
  195. package/src/primitives/Split.test.tsx +133 -0
  196. package/src/primitives/Split.tsx +175 -0
  197. package/src/primitives/StatusBar.stories.tsx +1 -1
  198. package/src/primitives/Toolbar.stories.tsx +1 -1
  199. package/src/primitives/ZoomControl.tsx +5 -2
  200. package/src/primitives/index.ts +2 -0
  201. package/src/specimen/Specimen.less +95 -0
  202. package/src/specimen/Specimen.stories.tsx +13 -0
  203. package/src/specimen/Specimen.test.tsx +13 -0
  204. package/src/specimen/Specimen.tsx +888 -0
  205. package/src/state/Persistence.stories.tsx +75 -0
  206. package/src/state/Persistence.tsx +33 -0
  207. package/src/state/SingletonExperiment.test.tsx +47 -53
  208. package/src/state/SingletonExperiment.tsx +24 -13
  209. package/src/state/adapterContract.ts +116 -0
  210. package/src/state/adapters.test.ts +113 -61
  211. package/src/state/adapters.ts +328 -69
  212. package/src/state/document.test.ts +71 -96
  213. package/src/state/document.ts +66 -52
  214. package/src/state/helpers.test.ts +1 -1
  215. package/src/state/index.ts +8 -0
  216. package/src/state/labRecords.test.ts +105 -0
  217. package/src/state/labRecords.ts +160 -0
  218. package/src/state/openLabStore.test.ts +658 -0
  219. package/src/state/openLabStore.ts +403 -0
  220. package/src/state/records.test.ts +234 -0
  221. package/src/state/records.ts +219 -0
  222. package/src/state/store.test.ts +169 -635
  223. package/src/state/store.ts +146 -163
  224. package/src/state/toolSlot.test.ts +1 -1
  225. package/src/state/types.ts +65 -17
  226. package/src/state/useOpenOnce.ts +68 -0
  227. package/src/state/usePersistedState.test.tsx +125 -0
  228. package/src/state/usePersistedState.ts +66 -0
  229. package/src/state/useTrialState.test.tsx +87 -3
  230. package/src/state/useTrialState.ts +9 -1
  231. package/src/state/view.test.ts +49 -15
  232. package/src/state/view.ts +18 -1
  233. package/src/styles.less +3 -0
  234. package/src/surface/AGENTS.md +39 -7
  235. package/src/surface/SurfaceContext.ts +26 -3
  236. package/src/surface/index.ts +2 -0
  237. package/src/surface/useSurfaceTile.test.tsx +1 -0
  238. package/src/surface/useSurfaceTile.ts +6 -5
  239. package/src/surface/useTiledSurface.test.tsx +231 -1
  240. package/src/surface/useTiledSurface.ts +76 -15
  241. package/src/theme/Interstellar.stories.tsx +9 -9
  242. package/src/theme/interstellar.test.ts +17 -6
  243. package/src/theme/interstellar.theme.json +180 -0
  244. package/src/theme/interstellar.ts +8 -5
  245. package/src/tools/labTool.ts +11 -0
  246. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  247. package/src/trial/Trial.annotations.test.tsx +59 -7
  248. package/src/trial/Trial.canvas.test.tsx +32 -0
  249. package/src/trial/Trial.config.test.tsx +75 -2
  250. package/src/trial/Trial.job.test.tsx +3 -3
  251. package/src/trial/Trial.less +5 -26
  252. package/src/trial/Trial.stories.tsx +5 -3
  253. package/src/trial/Trial.test.tsx +31 -4
  254. package/src/trial/Trial.trialId.test.tsx +23 -1
  255. package/src/trial/Trial.tsx +139 -52
  256. package/src/trial/TrialBody.tsx +17 -141
  257. package/src/trial/TrialChrome.tsx +24 -7
  258. package/src/trial/trialOps.test.ts +101 -1
  259. package/src/trial/trialOps.ts +67 -7
  260. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  261. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  262. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  263. package/dist/chunk-2P6PP5N4.js +0 -553
  264. package/dist/chunk-2P6PP5N4.js.map +0 -1
  265. package/dist/chunk-64ZCN3DA.js.map +0 -1
  266. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  267. package/dist/chunk-E44SU6XS.js +0 -50
  268. package/dist/chunk-E44SU6XS.js.map +0 -1
  269. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  270. package/dist/chunk-I6JCVE24.js.map +0 -1
  271. package/dist/chunk-MOM3GOVY.js.map +0 -1
  272. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  273. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  274. package/dist/chunk-U3IYHIAE.js +0 -343
  275. package/dist/chunk-U3IYHIAE.js.map +0 -1
  276. package/dist/chunk-W3ECWC2K.js +0 -7335
  277. package/dist/chunk-W3ECWC2K.js.map +0 -1
  278. package/src/theme/interstellar.tokens.json +0 -61
@@ -4,10 +4,11 @@
4
4
  * `SingletonExperiment` — so `useTrialState()` threw everywhere else, which
5
5
  * made the documented hook pattern unreachable from an instrument's `render`.
6
6
  */
7
- import { render, screen } from '@testing-library/react';
7
+ import { act, render, screen } from '@testing-library/react';
8
8
  import { beforeAll, describe, expect, it, vi } from 'vitest';
9
9
  import { defineInstrument } from '../instrument/defineInstrument';
10
10
  import { Lab } from '../lab/Lab';
11
+ import { LabContext, type LabContextValue } from '../lab/LabContext';
11
12
  import { useTrialState } from '../state/useTrialState';
12
13
 
13
14
  beforeAll(() => {
@@ -34,3 +35,24 @@ describe('<Trial> trial-scoped hooks', () => {
34
35
  expect(screen.getByTestId('probe')).toHaveTextContent('hits:7');
35
36
  });
36
37
  });
38
+
39
+ describe('<Trial> root element', () => {
40
+ it('carries its trial id, so a lab can find a trial from outside', () => {
41
+ let labRef: LabContextValue | null = null;
42
+ const { container } = render(
43
+ <Lab instruments={[reader]} defaultInstrument="Reader">
44
+ <LabContext.Consumer>
45
+ {(value) => {
46
+ if (value) labRef = value;
47
+ return null;
48
+ }}
49
+ </LabContext.Consumer>
50
+ </Lab>,
51
+ );
52
+ act(() => (labRef as LabContextValue | null)?.addTrial('Reader'));
53
+ const ids = (labRef as LabContextValue | null)?.trials.map((t) => t.id) ?? [];
54
+ expect(ids).toHaveLength(2);
55
+ const roots = container.querySelectorAll<HTMLElement>('.lk-trial[data-trial-id]');
56
+ expect([...roots].map((el) => el.dataset.trialId)).toEqual(ids);
57
+ });
58
+ });
@@ -2,15 +2,26 @@ import { type ReactNode, useContext, useEffect, useMemo, useRef, useState } from
2
2
  import { useStore } from 'zustand/react';
3
3
  import { AnnotationsContext } from '../annotations/AnnotationsContext';
4
4
  import { AnnotationTargets } from '../annotations/AnnotationTargets';
5
+ import { AnnotationPreloadContext } from '../annotations/preload';
5
6
  import { annotationsFromJSON } from '../annotations/store';
6
- import type { AnnotationTargetInfo } from '../annotations/types';
7
+ import type { AnnotationStorage, AnnotationTargetInfo } from '../annotations/types';
8
+ import { CameraWheelContext, type CameraWheelSlot } from '../canvas/CameraWheelContext';
7
9
  import { CanvasStack } from '../canvas/CanvasStack';
10
+ import { fitStage, Stage } from '../canvas/Stage';
8
11
  import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
9
12
  import { applyCamera, type ViewportSize } from '../canvas/worldSpec';
10
13
  import type { TrialContribution } from '../chrome/types';
14
+ import { useConfigSchema } from '../config/useConfigSchema';
15
+ import { useResolvedConfig } from '../config/useResolvedConfig';
11
16
  import { DragOverlay, useDragDrop } from '../dragdrop/DragDropRuntime';
12
17
  import { Palette } from '../dragdrop/Palette';
13
- import type { Instrument, LayerDescriptor, PaletteItem, RenderContext } from '../instrument/types';
18
+ import type {
19
+ Instrument,
20
+ InstrumentList,
21
+ LayerDescriptor,
22
+ PaletteItem,
23
+ RenderContext,
24
+ } from '../instrument/types';
14
25
  import { useJob } from '../job/useJob';
15
26
  import { useLabContext } from '../lab/LabContext';
16
27
  import { LayerList } from '../layers/LayerList';
@@ -19,7 +30,8 @@ import { resolveLoupe } from '../loupe/types';
19
30
  import { LabStoreContext, TrialIdProvider } from '../state/context';
20
31
  import type { LabStore } from '../state/store';
21
32
  import type { TrialRecord } from '../state/types';
22
- import { as2DView, DEFAULT_VIEW } from '../state/view';
33
+ import { as2DView, DEFAULT_VIEW, withZoom } from '../state/view';
34
+ import { resolveLabTool } from '../tools/labTool';
23
35
  import { createEventBus, type EventBus } from '../undo/eventBus';
24
36
  import { pushSnapshot, redo as undoRedo, undo as undoUndo } from '../undo/undoStack';
25
37
  import type { LoupeBindings, UndoBindings } from './TrialChrome';
@@ -41,20 +53,25 @@ export function Trial({ id, chrome, suppress }: TrialProps) {
41
53
  const lab = useLabContext();
42
54
  const storeCtx = useContext(LabStoreContext);
43
55
  if (!storeCtx) throw new Error('[labkit] <Trial> requires <LabStoreProvider>');
56
+ const instruments = useStore(storeCtx.store, (s) => s.instruments) ?? lab.instruments;
44
57
  const record = useStore(storeCtx.store, (s) => s.trials.find((w) => w.id === id));
58
+ const instrument = record ? instruments.find((i) => i.name === record.instrumentName) : undefined;
59
+ const kept = useKeptMarks(instrument?.annotations?.storage, id);
45
60
  if (!record) {
46
61
  return <div className="lk-trial lk-trial--unknown">Trial not found: {id}</div>;
47
62
  }
48
- const instrument = lab.instruments.find((i) => i.name === record.instrumentName);
49
63
  if (!instrument) {
50
64
  return (
51
65
  <div className="lk-trial lk-trial--unknown">Unknown instrument: {record.instrumentName}</div>
52
66
  );
53
67
  }
68
+ if (!kept.ready) return <div className="lk-trial lk-trial--loading" />;
54
69
  return (
55
70
  <TrialRuntime
71
+ keptMarks={kept.value}
56
72
  record={record}
57
73
  instrument={instrument}
74
+ instruments={instruments}
58
75
  store={storeCtx.store}
59
76
  isLast={lab.trials.length <= 1}
60
77
  chrome={chrome}
@@ -63,16 +80,60 @@ export function Trial({ id, chrome, suppress }: TrialProps) {
63
80
  );
64
81
  }
65
82
 
83
+ /** The marks an instrument keeps in its own storage: preloaded by the lab for
84
+ * the trials it opened with, loaded here for any added since. */
85
+ function useKeptMarks(
86
+ storage: AnnotationStorage | undefined,
87
+ trialId: string,
88
+ ): { ready: boolean; value: unknown } {
89
+ const preload = useContext(AnnotationPreloadContext);
90
+ const [loaded, setLoaded] = useState<{ value: unknown } | null>(() =>
91
+ storage && preload?.has(trialId) ? { value: preload.get(trialId) } : null,
92
+ );
93
+ useEffect(() => {
94
+ if (!storage || loaded) return;
95
+ let live = true;
96
+ storage.load().then(
97
+ (value) => {
98
+ if (live) setLoaded({ value });
99
+ },
100
+ (error) => {
101
+ console.warn(`[labkit] could not load the marks of trial "${trialId}"`, error);
102
+ if (live) setLoaded({ value: null });
103
+ },
104
+ );
105
+ return () => {
106
+ live = false;
107
+ };
108
+ }, [storage, loaded, trialId]);
109
+ if (!storage) return { ready: true, value: undefined };
110
+ return loaded ? { ready: true, value: loaded.value } : { ready: false, value: undefined };
111
+ }
112
+
66
113
  interface TrialRuntimeProps {
114
+ /** The marks an instrument's own `AnnotationStorage` loaded. */
115
+ keptMarks: unknown;
67
116
  record: TrialRecord;
68
117
  instrument: Instrument;
118
+ /** The lab's instruments as the store holds them now. */
119
+ instruments: InstrumentList;
69
120
  store: LabStore;
70
121
  isLast: boolean;
71
122
  chrome?: readonly TrialContribution[];
72
123
  suppress?: readonly string[];
73
124
  }
74
125
 
75
- function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: TrialRuntimeProps) {
126
+ function TrialRuntime({
127
+ keptMarks,
128
+ record,
129
+ instrument,
130
+ instruments,
131
+ store,
132
+ isLast,
133
+ chrome,
134
+ suppress,
135
+ }: TrialRuntimeProps) {
136
+ const wheelSlot = useRef<CameraWheelSlot['current']>(null);
76
137
  const canvasContainerRef = useRef<HTMLDivElement | null>(null);
77
138
  const loupeHostRef = useRef<HTMLDivElement | null>(null);
78
139
  const updateTrialState = useStore(store, (s) => s.updateTrialState);
@@ -84,6 +145,11 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
84
145
  const setLabTool = useStore(store, (s) => s.setLabTool);
85
146
  const setTrialTool = useStore(store, (s) => s.setTrialTool);
86
147
 
148
+ // Everything below reads `config`; `record.config` is the panel's view, and
149
+ // reaches it through `record` on `TrialChrome`.
150
+ const schema = useConfigSchema(instrument);
151
+ const { config } = useResolvedConfig(schema, record.config, record.auto);
152
+
87
153
  const busRef = useRef<EventBus | null>(null);
88
154
  if (busRef.current === null) busRef.current = createEventBus();
89
155
  const bus = busRef.current;
@@ -117,8 +183,11 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
117
183
  // A function `initialView` needs the canvas size, so `trialOps` leaves the
118
184
  // view null and the first measurement resolves it. Reset nulls it again,
119
185
  // which re-frames — so the guard is the null itself, not a "have I run" flag.
186
+ const stage = instrument.canvas ? undefined : instrument.stage;
120
187
  const placeView = (size: ViewportSize): void => {
121
- const declared = instrument.canvas?.initialView;
188
+ const declared =
189
+ instrument.canvas?.initialView ??
190
+ (stage ? (stage.initialView ?? ((vp: ViewportSize) => fitStage(stage.size, vp))) : undefined);
122
191
  if (typeof declared !== 'function') return;
123
192
  if (record.view != null) return;
124
193
  setView(declared(size));
@@ -131,24 +200,26 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
131
200
  const targetsRef = useRef<() => readonly AnnotationTargetInfo[]>(() => []);
132
201
  targetsRef.current = () =>
133
202
  annotationsCap
134
- ? annotationsCap.targets(record.state, record.config, { id: record.id, view: record.view })
203
+ ? annotationsCap.targets(record.state, config, { id: record.id, view: record.view })
135
204
  : [];
136
205
 
137
- // One store for the trial's lifetime. Marks do not survive a reload yet —
138
- // the storage slot is 3d.
206
+ // One store for the trial's lifetime.
139
207
  const annotationsRef = useRef<ReturnType<typeof annotationsFromJSON> | null>(null);
140
208
  // Read through refs for the same reason `targets` is: the store is built
141
209
  // once, and an export must draw against the config the trial holds now.
142
- const configRef = useRef<unknown>(record.config);
143
- configRef.current = record.config;
210
+ const configRef = useRef<unknown>(config);
211
+ configRef.current = config;
212
+ // The instrument can be swapped under a live trial, so the capability is read at use too.
213
+ const capRef = useRef(annotationsCap);
214
+ capRef.current = annotationsCap;
144
215
  if (annotationsRef.current === null) {
145
216
  // Seeded from wherever the marks were kept: the instrument's own store if
146
217
  // it declared one, else this trial's slot.
147
- const kept = annotationsCap?.storage ? annotationsCap.storage.load() : record.annotations;
218
+ const kept = annotationsCap?.storage ? keptMarks : record.annotations;
148
219
  annotationsRef.current = annotationsFromJSON(kept, () => targetsRef.current(), {
149
- meaning: annotationsCap?.meaning,
220
+ meaning: () => capRef.current?.meaning,
150
221
  config: () => configRef.current,
151
- onCapture: (result) => annotationsCap?.onCapture?.(result),
222
+ onCapture: (result) => capRef.current?.onCapture?.(result),
152
223
  });
153
224
  }
154
225
  const annotations = annotationsRef.current;
@@ -203,15 +274,14 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
203
274
 
204
275
  // A trial gets its own slot when its instrument declares tools; otherwise it
205
276
  // reads the lab's. Which slot a change writes follows from the same thing.
206
- // Annotation tools are a trial's own for the same reason: two trials
207
- // annotating different pictures must not share one active tool.
208
- const declaresTools = instrument.tools != null || instrument.annotations != null;
277
+ // Annotation tools always read the lab's: a tool is what the hand holds, so
278
+ // picking one arms it in every trial, the way artboards share a tool. They
279
+ // were once per trial, back when one trial compared several pictures.
280
+ const declaresTools = instrument.tools != null;
281
+ const labTool = resolveLabTool(labToolId, instruments);
209
282
  const resolvedToolId = declaresTools
210
- ? (record.activeToolId ??
211
- instrument.tools?.initial ??
212
- instrument.tools?.tools[0]?.id ??
213
- (instrument.annotations ? 'select' : null))
214
- : labToolId;
283
+ ? (record.activeToolId ?? instrument.tools?.initial ?? instrument.tools?.tools[0]?.id ?? null)
284
+ : labTool;
215
285
  const setActiveTool = (id: string): void => {
216
286
  if (declaresTools) setTrialTool(record.id, id);
217
287
  else setLabTool(id);
@@ -226,14 +296,14 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
226
296
  // this — with a runner that yields nothing and is never started.
227
297
  const job = useJob({
228
298
  capability: jobCap ?? { run: async function* () {}, onItem: (_i, st) => st },
229
- config: record.config,
299
+ config,
230
300
  state: record.state,
231
301
  setState: (next) => updateTrialState(record.id, next as never),
232
302
  });
233
303
 
234
304
  const renderCtx: RenderContext<unknown, unknown> = {
235
305
  state: record.state,
236
- config: record.config,
306
+ config,
237
307
  setState: (next) => {
238
308
  snapshotIfNeeded('state.change');
239
309
  updateTrialState(record.id, next);
@@ -254,7 +324,7 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
254
324
  zoom: view2d?.zoom ?? 1,
255
325
  setZoom: (z) => {
256
326
  if (!view2d) return;
257
- updateTrialView(record.id, { ...view2d, zoom: z });
327
+ updateTrialView(record.id, withZoom(view2d, z));
258
328
  },
259
329
  activeToolId: resolvedToolId,
260
330
  visibleLayers,
@@ -302,8 +372,8 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
302
372
  const loupeCap = useMemo(() => {
303
373
  const declared = instrument.loupe;
304
374
  if (declared == null) return null;
305
- return resolveLoupe(typeof declared === 'function' ? declared(record.config) : declared);
306
- }, [instrument.loupe, record.config]);
375
+ return resolveLoupe(typeof declared === 'function' ? declared(config) : declared);
376
+ }, [instrument.loupe, config]);
307
377
  const loupeBindings: LoupeBindings | undefined = loupeCap
308
378
  ? { on: loupeOn, toggle: () => setLoupeOn((v) => !v) }
309
379
  : undefined;
@@ -321,10 +391,10 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
321
391
  // Camera applied here, so a layer draws in world coordinates. `zoom`
322
392
  // stays in the args for line widths, which must not scale with it.
323
393
  applyCamera(ctx, view, frame);
324
- layer.draw(ctx, { state: record.state, config: record.config, zoom: view.zoom });
394
+ layer.draw(ctx, { state: record.state, config, zoom: view.zoom });
325
395
  },
326
396
  }));
327
- }, [instrument.canvas, record.state, record.config, layerVisibility, layerOrder]);
397
+ }, [instrument.canvas, record.state, config, layerVisibility, layerOrder]);
328
398
 
329
399
  const layerDescriptors: LayerDescriptor[] = useMemo(() => {
330
400
  if (!instrument.layers) return [];
@@ -337,7 +407,7 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
337
407
  view: view2d ?? DEFAULT_VIEW,
338
408
  worldSpec: instrument.canvas?.worldSpec,
339
409
  state: record.state,
340
- config: record.config,
410
+ config,
341
411
  setState: (next) => {
342
412
  snapshotIfNeeded('canvas.itemAdded');
343
413
  updateTrialState(record.id, next as never);
@@ -351,8 +421,8 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
351
421
  const paletteItems: PaletteItem[] = useMemo(() => {
352
422
  if (!instrument.dragDrop) return [];
353
423
  const p = instrument.dragDrop.palette;
354
- return typeof p === 'function' ? p(record.state, record.config) : p;
355
- }, [instrument.dragDrop, record.state, record.config]);
424
+ return typeof p === 'function' ? p(record.state, config) : p;
425
+ }, [instrument.dragDrop, record.state, config]);
356
426
 
357
427
  const layersWithFeedback: CanvasLayerDescriptor[] = useMemo(() => {
358
428
  if (!dragDropResult.drag?.feedback) return canvasLayers;
@@ -386,7 +456,7 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
386
456
  capability={loupeCap}
387
457
  enabled={loupeOn}
388
458
  state={record.state}
389
- config={record.config}
459
+ config={config}
390
460
  view={view2d ?? DEFAULT_VIEW}
391
461
  worldSpec={instrument.canvas?.worldSpec}
392
462
  hostRef={loupeHostRef}
@@ -412,6 +482,21 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
412
482
  <DragOverlay drag={dragDropResult.drag} />
413
483
  </div>
414
484
  );
485
+ } else if (stage) {
486
+ body = (
487
+ <Stage
488
+ size={stage.size}
489
+ view={view2d ?? DEFAULT_VIEW}
490
+ onViewChange={setView}
491
+ onResize={placeView}
492
+ minZoom={stage.minZoom}
493
+ maxZoom={stage.maxZoom}
494
+ hostRef={loupeHostRef}
495
+ overlay={lens}
496
+ >
497
+ {instrument.render(renderCtx)}
498
+ </Stage>
499
+ );
415
500
  } else if (lens) {
416
501
  body = (
417
502
  <div ref={loupeHostRef} className="lk-trial__loupe-host">
@@ -475,33 +560,35 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
475
560
  <AnnotationTargets
476
561
  capability={annotationsCap}
477
562
  state={record.state}
478
- config={record.config}
563
+ config={config}
479
564
  trial={{ id: record.id, view: record.view }}
480
565
  annotations={annotations}
481
- activeToolId={resolvedToolId}
566
+ activeToolId={labTool}
482
567
  />
483
568
  ) : null;
484
569
 
485
570
  return (
486
571
  <TrialIdProvider trialId={record.id}>
487
572
  <AnnotationsContext.Provider value={annotationsCap ? annotations : null}>
488
- <TrialChrome
489
- job={jobCap ? job : undefined}
490
- loupe={loupeBindings}
491
- trialId={record.id}
492
- record={record}
493
- instrument={instrument}
494
- isLastTrial={isLast}
495
- undoBindings={undoBindings}
496
- trialChrome={extraChrome}
497
- chrome={chrome}
498
- suppress={suppress}
499
- activeToolId={resolvedToolId}
500
- setActiveTool={setActiveTool}
501
- >
502
- {body}
503
- {annotationOverlays}
504
- </TrialChrome>
573
+ <CameraWheelContext.Provider value={wheelSlot}>
574
+ <TrialChrome
575
+ job={jobCap ? job : undefined}
576
+ loupe={loupeBindings}
577
+ trialId={record.id}
578
+ record={record}
579
+ instrument={instrument}
580
+ isLastTrial={isLast}
581
+ undoBindings={undoBindings}
582
+ trialChrome={extraChrome}
583
+ chrome={chrome}
584
+ suppress={suppress}
585
+ activeToolId={resolvedToolId}
586
+ setActiveTool={setActiveTool}
587
+ >
588
+ {body}
589
+ {annotationOverlays}
590
+ </TrialChrome>
591
+ </CameraWheelContext.Provider>
505
592
  </AnnotationsContext.Provider>
506
593
  </TrialIdProvider>
507
594
  );
@@ -1,14 +1,9 @@
1
- import { type ReactNode, useEffect, useMemo, useRef } from 'react';
2
- import { asNodeId, createNode, Store, stripStrategy } from 'windease';
3
- import { type ChromeMap, Container, Provider, StrategyRegistryProvider } from 'windease/react';
4
-
5
- const ZONE_ID = asNodeId('lk-trial-body');
6
- const SIDEBAR_ID = asNodeId('lk-trial-sidebar');
7
- const CONTENT_ID = asNodeId('lk-trial-content');
8
- const STRATEGIES = { strip: stripStrategy as never };
1
+ import type { ReactNode } from 'react';
2
+ import { Split, type SplitProps } from '../primitives/Split';
9
3
 
10
4
  /** Props for `<TrialBody>`. */
11
- export interface TrialBodyProps {
5
+ export interface TrialBodyProps
6
+ extends Omit<SplitProps, 'sidebar' | 'children' | 'className' | 'sidebarClassName' | 'label'> {
12
7
  /** The sidebar region's sections. */
13
8
  sidebar: ReactNode;
14
9
  /** The instrument's own output. */
@@ -16,140 +11,21 @@ export interface TrialBodyProps {
16
11
  /** Extra class on the content pane — `Trial` marks a canvas instrument's
17
12
  * well flush. */
18
13
  contentClassName?: string;
19
- /** The sidebar's current extent in pixels. Omit for an unpersisted lab: the
20
- * seam still works, its result just does not outlive the mount. */
21
- width?: number;
22
- /** Where the sidebar starts before anyone drags it. */
23
- defaultWidth?: number;
24
- minWidth?: number;
25
- maxWidth?: number;
26
- /** How little the instrument may be squeezed to. Reported as the seam's
27
- * reachable maximum, so the drag stops there rather than clamping silently. */
28
- contentMinWidth?: number;
29
- /** Fires through the drag, not on release: the pane is already there. */
30
- onWidthChange?: (width: number) => void;
31
- /**
32
- * Fixed extent. Omit in an app — the strip measures its own box. Supply it
33
- * where nothing measures, notably jsdom, or both panes lay out at zero.
34
- */
35
- viewport?: { w: number; h: number };
36
14
  }
37
15
 
38
- /**
39
- * A trial's sidebar and content as a two-pane strip with a draggable seam.
40
- *
41
- * The seam is `stripStrategy`'s own resize affordance, so it arrives as a
42
- * `role="separator"` carrying the reachable range, operable by pointer and by
43
- * arrows / Home / End, and clamped against the content pane's floor rather
44
- * than against the sidebar's alone.
45
- */
46
- export function TrialBody({
47
- sidebar,
48
- children,
49
- contentClassName,
50
- width,
51
- defaultWidth = 320,
52
- minWidth = 140,
53
- maxWidth = 720,
54
- contentMinWidth = 160,
55
- onWidthChange,
56
- viewport,
57
- }: TrialBodyProps) {
58
- // The cross-axis half of every size hint below is inert: a horizontal strip
59
- // stretches its panes to the container's height and never reads `h`.
60
- const storeRef = useRef<Store | null>(null);
61
- if (storeRef.current === null) {
62
- const store = new Store();
63
- store.registerNode(
64
- createNode({
65
- kind: 'zone',
66
- id: ZONE_ID,
67
- container: { strategyId: 'strip', config: { axis: 'x', resizable: true } },
68
- }),
69
- );
70
- store.registerNode(
71
- createNode({
72
- kind: 'sidebar',
73
- id: SIDEBAR_ID,
74
- parentId: ZONE_ID,
75
- meta: { title: 'Sidebar' },
76
- placement: { size: { w: width ?? defaultWidth } },
77
- hints: { minSize: { w: minWidth, h: 0 }, maxSize: { w: maxWidth, h: 0 } },
78
- }),
79
- );
80
- store.registerNode(
81
- createNode({
82
- kind: 'content',
83
- id: CONTENT_ID,
84
- parentId: ZONE_ID,
85
- meta: { title: 'Content' },
86
- hints: { minSize: { w: contentMinWidth, h: 0 } },
87
- }),
88
- );
89
- store.showNode(SIDEBAR_ID);
90
- store.showNode(CONTENT_ID);
91
- storeRef.current = store;
92
- }
93
- const store = storeRef.current;
94
-
95
- // What the store last held, so the placement event can tell a drag from the
96
- // echo of our own write and a controlled `width` does not fight the seam.
97
- const widthRef = useRef(width ?? defaultWidth);
98
-
99
- useEffect(() => {
100
- store.setHints(SIDEBAR_ID, {
101
- minSize: { w: minWidth, h: 0 },
102
- maxSize: { w: maxWidth, h: 0 },
103
- });
104
- store.setHints(CONTENT_ID, { minSize: { w: contentMinWidth, h: 0 } });
105
- }, [store, minWidth, maxWidth, contentMinWidth]);
106
-
107
- useEffect(() => {
108
- if (width === undefined || width === widthRef.current) return;
109
- widthRef.current = width;
110
- store.patchPlacement(SIDEBAR_ID, { size: { w: width } });
111
- }, [store, width]);
112
-
113
- const onWidthChangeRef = useRef(onWidthChange);
114
- onWidthChangeRef.current = onWidthChange;
115
- useEffect(
116
- () =>
117
- store.events.on('node.placementChanged', () => {
118
- const next = (store.getPlacement(SIDEBAR_ID)?.size as { w?: number } | undefined)?.w;
119
- if (typeof next !== 'number' || next === widthRef.current) return;
120
- widthRef.current = next;
121
- onWidthChangeRef.current?.(next);
122
- }),
123
- [store],
124
- );
125
-
126
- const chrome = useMemo<ChromeMap>(
127
- () => ({
128
- sidebar: () => <div className="lk-trial__sidebar">{sidebar}</div>,
129
- content: () => (
130
- <div
131
- className={
132
- contentClassName ? `lk-trial__content ${contentClassName}` : 'lk-trial__content'
133
- }
134
- >
135
- {children}
136
- </div>
137
- ),
138
- }),
139
- [sidebar, children, contentClassName],
140
- );
141
-
16
+ /** A trial's sidebar and content as a `Split`, wearing the trial's classes. */
17
+ export function TrialBody({ sidebar, children, contentClassName, ...rest }: TrialBodyProps) {
142
18
  return (
143
- <Provider store={store}>
144
- <StrategyRegistryProvider strategies={STRATEGIES}>
145
- <Container
146
- parentId={ZONE_ID}
147
- chrome={chrome}
148
- className="lk-trial__panes windease-zone"
149
- affordances
150
- viewport={viewport}
151
- />
152
- </StrategyRegistryProvider>
153
- </Provider>
19
+ <Split
20
+ {...rest}
21
+ sidebar={sidebar}
22
+ className="lk-trial__panes"
23
+ sidebarClassName="lk-trial__sidebar"
24
+ contentClassName={
25
+ contentClassName ? `lk-trial__content ${contentClassName}` : 'lk-trial__content'
26
+ }
27
+ >
28
+ {children}
29
+ </Split>
154
30
  );
155
31
  }
@@ -9,15 +9,17 @@ import { TitleBarRegion } from '../chrome/regions/TitleBarRegion';
9
9
  import { ToolbarRegion } from '../chrome/regions/ToolbarRegion';
10
10
  import { ViewportRegion } from '../chrome/regions/ViewportRegion';
11
11
  import type { TrialChromeContext, TrialContribution, TrialRegion } from '../chrome/types';
12
- import { hasConfigPath, schemaNodeAtPath, withValueAtPath } from '../config/path';
12
+ import { applyConfigWrite, resolveAutoConfig } from '../config/autoConfig';
13
+ import { hasConfigPath, schemaNodeAtPath } from '../config/path';
13
14
  import { useConfigSchema } from '../config/useConfigSchema';
15
+ import { useResolvedConfig } from '../config/useResolvedConfig';
14
16
  import type { Instrument } from '../instrument/types';
15
17
  import type { JobHandle } from '../job/types';
16
18
  import { useLabContext } from '../lab/LabContext';
17
19
  import { JobProgress } from '../primitives/JobProgress';
18
20
  import { LabStoreContext } from '../state/context';
19
21
  import type { TrialRecord } from '../state/types';
20
- import { as2DView } from '../state/view';
22
+ import { as2DView, withZoom } from '../state/view';
21
23
  import { TrialBody } from './TrialBody';
22
24
  import { TrialTitleBar } from './TrialTitleBar';
23
25
  import { UndockedSections } from './UndockedSections';
@@ -110,14 +112,21 @@ export function TrialChrome({
110
112
  const view2d = as2DView(record.view);
111
113
 
112
114
  const configSchema = useConfigSchema(instrument);
115
+ // What the instrument is looking at right now. The panel reads `record.config`
116
+ // — the pinned values — but anything instrument-facing gets the resolved one.
117
+ const { config: resolvedConfig, auto } = useResolvedConfig(
118
+ configSchema,
119
+ record.config,
120
+ record.auto,
121
+ );
113
122
 
114
- const title = record.title ?? record.instrumentName;
123
+ const title = record.title ?? instrument.title ?? instrument.name;
115
124
  const collapsedSections = record.collapsedSections ?? NO_SECTIONS;
116
125
 
117
126
  const ctx = useMemo<TrialChromeContext>(() => {
118
127
  const setZoom = (z: number): void => {
119
128
  if (!view2d) return;
120
- updateTrialView(trialId, { ...view2d, zoom: z });
129
+ updateTrialView(trialId, withZoom(view2d, z));
121
130
  };
122
131
  return {
123
132
  trialId,
@@ -136,6 +145,7 @@ export function TrialChrome({
136
145
  configSchema,
137
146
  configFields: instrument.config ? [] : (instrument.configSchema?.() ?? []),
138
147
  config: record.config,
148
+ auto,
139
149
  setConfig: (path, value) => {
140
150
  const prevConfig = record.config as Record<string, unknown>;
141
151
  if (
@@ -149,8 +159,9 @@ export function TrialChrome({
149
159
  }
150
160
  updateTrialConfig(trialId, path, value);
151
161
  if (instrument.onConfigChange) {
152
- const nextConfig = withValueAtPath(prevConfig, path, value);
153
- const nextState = instrument.onConfigChange(nextConfig, prevConfig, record.state);
162
+ const next = applyConfigWrite(prevConfig, record.auto ?? [], path, value);
163
+ const nextConfig = resolveAutoConfig(configSchema, next.config, new Set(next.autoPaths));
164
+ const nextState = instrument.onConfigChange(nextConfig, resolvedConfig, record.state);
154
165
  updateTrialState(trialId, nextState as never);
155
166
  }
156
167
  },
@@ -184,6 +195,8 @@ export function TrialChrome({
184
195
  activeToolId,
185
196
  setActiveTool,
186
197
  configSchema,
198
+ resolvedConfig,
199
+ auto,
187
200
  undockedIds,
188
201
  undockPanelAction,
189
202
  dockPanelAction,
@@ -225,6 +238,8 @@ export function TrialChrome({
225
238
  return (
226
239
  <section
227
240
  className="lk-trial"
241
+ data-trial-id={trialId}
242
+ data-focused={lab.trials.length > 1 && lab.focusedTrialId === trialId ? 'true' : undefined}
228
243
  aria-label={`Trial ${title}`}
229
244
  tabIndex={-1}
230
245
  onKeyDown={handleKeyDown}
@@ -243,7 +258,9 @@ export function TrialChrome({
243
258
  <TrialBody
244
259
  width={record.sidebarWidth}
245
260
  onWidthChange={(w) => updateTrialSidebarWidth(trialId, w)}
246
- contentClassName={instrument.canvas ? 'lk-trial__content--flush' : undefined}
261
+ contentClassName={
262
+ instrument.canvas || instrument.stage ? 'lk-trial__content--flush' : undefined
263
+ }
247
264
  sidebar={
248
265
  <>
249
266
  <SidebarRegion contributions={inRegion('sidebar')} ctx={ctx} />