@weasel-js/labkit 1.0.4 → 1.2.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 (122) hide show
  1. package/README.md +33 -6
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-C_XboUpz.d.ts} +48 -6
  3. package/dist/_dts/{index-CFlDPeZh.d.ts → index-JFAYj5Tv.d.ts} +29 -7
  4. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  5. package/dist/_dts/useTrialState-BMNIx3Cy.d.ts +171 -0
  6. package/dist/canvas/index.d.ts +2 -1
  7. package/dist/{chunk-BAPZPDDA.js → chunk-73KA7WBO.js} +425 -394
  8. package/dist/chunk-73KA7WBO.js.map +1 -0
  9. package/dist/{chunk-DWV7SFKR.js → chunk-BOHF3PQO.js} +4397 -3726
  10. package/dist/chunk-BOHF3PQO.js.map +1 -0
  11. package/dist/{chunk-DEWXYFEU.js → chunk-G5TJVQQT.js} +3 -3
  12. package/dist/{chunk-DEWXYFEU.js.map → chunk-G5TJVQQT.js.map} +1 -1
  13. package/dist/chunk-LN6JDUGB.js +106 -0
  14. package/dist/chunk-LN6JDUGB.js.map +1 -0
  15. package/dist/chunk-THBG7FQZ.js +167 -0
  16. package/dist/chunk-THBG7FQZ.js.map +1 -0
  17. package/dist/chunk-VUU5UXHE.js +491 -0
  18. package/dist/chunk-VUU5UXHE.js.map +1 -0
  19. package/dist/dragdrop/index.d.ts +2 -1
  20. package/dist/index.d.ts +211 -151
  21. package/dist/index.js +317 -169
  22. package/dist/index.js.map +1 -1
  23. package/dist/job/index.d.ts +13 -0
  24. package/dist/job/index.js +3 -0
  25. package/dist/job/index.js.map +1 -0
  26. package/dist/layers/index.d.ts +3 -2
  27. package/dist/passthrough/weasel-canvas.d.ts +1 -1
  28. package/dist/passthrough/weasel-canvas.js +1 -1
  29. package/dist/passthrough/weasel-ui.d.ts +29 -6
  30. package/dist/passthrough/weasel-ui.js +2 -2
  31. package/dist/state/index.d.ts +12 -12
  32. package/dist/state/index.js +5 -5
  33. package/dist/state/index.js.map +1 -1
  34. package/dist/styles.css +31 -13
  35. package/dist/surface/index.d.ts +77 -0
  36. package/dist/surface/index.js +3 -0
  37. package/dist/surface/index.js.map +1 -0
  38. package/dist/ui/layers/index.js +3 -3
  39. package/dist/undo/index.d.ts +2 -1
  40. package/package.json +9 -1
  41. package/src/canvas/AGENTS.md +14 -2
  42. package/src/canvas/useOrbit.test.ts +71 -0
  43. package/src/canvas/useOrbit.ts +149 -0
  44. package/src/dragdrop/dragDrop.test.tsx +4 -7
  45. package/src/index.test.ts +70 -0
  46. package/src/index.ts +24 -7
  47. package/src/instrument/SineWave.smoke.test.tsx +2 -1
  48. package/src/instrument/capabilityDetector.ts +1 -1
  49. package/src/instrument/types.ts +30 -6
  50. package/src/job/index.ts +3 -0
  51. package/src/job/types.ts +47 -0
  52. package/src/job/useJob.test.tsx +210 -0
  53. package/src/job/useJob.ts +134 -0
  54. package/src/lab/Lab.less +16 -0
  55. package/src/lab/Lab.stories.tsx +6 -7
  56. package/src/lab/Lab.test.tsx +25 -25
  57. package/src/lab/Lab.tsx +50 -50
  58. package/src/lab/LabContext.ts +12 -12
  59. package/src/lab/LabShell.tsx +1 -1
  60. package/src/lab/{WorkspaceGrid.less → Workspace.less} +2 -2
  61. package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +13 -13
  62. package/src/lab/Workspace.surface.test.tsx +49 -0
  63. package/src/lab/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
  64. package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +32 -19
  65. package/src/lab/index.ts +2 -2
  66. package/src/layers/AGENTS.md +3 -3
  67. package/src/primitives/Toolbar.stories.tsx +2 -2
  68. package/src/state/SingletonExperiment.test.tsx +8 -8
  69. package/src/state/SingletonExperiment.tsx +9 -9
  70. package/src/state/context.tsx +12 -12
  71. package/src/state/document.test.ts +369 -0
  72. package/src/state/document.ts +194 -0
  73. package/src/state/helpers.test.ts +74 -46
  74. package/src/state/helpers.ts +21 -29
  75. package/src/state/index.ts +16 -8
  76. package/src/state/store.test.ts +440 -41
  77. package/src/state/store.ts +165 -102
  78. package/src/state/types.ts +32 -13
  79. package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
  80. package/src/state/useTrialState.ts +29 -0
  81. package/src/state/view.test.ts +127 -0
  82. package/src/state/view.ts +18 -0
  83. package/src/styles.less +2 -2
  84. package/src/surface/AGENTS.md +64 -0
  85. package/src/surface/SurfaceContext.ts +5 -0
  86. package/src/surface/composeRects.test.ts +50 -0
  87. package/src/surface/composeRects.ts +19 -0
  88. package/src/surface/deviceRect.test.ts +40 -0
  89. package/src/surface/deviceRect.ts +19 -0
  90. package/src/surface/index.ts +7 -0
  91. package/src/surface/rect.ts +16 -0
  92. package/src/surface/useSurfaceTile.test.tsx +67 -0
  93. package/src/surface/useSurfaceTile.ts +32 -0
  94. package/src/surface/useTiledSurface.test.tsx +231 -0
  95. package/src/surface/useTiledSurface.ts +157 -0
  96. package/src/theme/base.less +1 -1
  97. package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
  98. package/src/trial/DefaultStatusBar.tsx +19 -0
  99. package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
  100. package/src/trial/Trial.canvas.test.tsx +80 -0
  101. package/src/trial/Trial.job.test.tsx +75 -0
  102. package/src/{workspace/Workspace.less → trial/Trial.less} +13 -1
  103. package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +20 -18
  104. package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
  105. package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +77 -46
  106. package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +73 -46
  107. package/src/{workspace → trial}/index.ts +13 -13
  108. package/src/{workspace → trial}/slotTypes.ts +18 -17
  109. package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +38 -44
  110. package/src/trial/trialOps.ts +99 -0
  111. package/src/ui/format.test.ts +33 -0
  112. package/src/ui/format.ts +11 -0
  113. package/src/ui/properties/CurveField.tsx +6 -6
  114. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
  115. package/dist/_dts/useExperimentState-D7EQnnwJ.d.ts +0 -151
  116. package/dist/chunk-BAPZPDDA.js.map +0 -1
  117. package/dist/chunk-DWV7SFKR.js.map +0 -1
  118. package/dist/chunk-KSTEW2AF.js +0 -367
  119. package/dist/chunk-KSTEW2AF.js.map +0 -1
  120. package/src/state/useExperimentState.ts +0 -31
  121. package/src/workspace/DefaultStatusBar.tsx +0 -18
  122. package/src/workspace/workspaceOps.ts +0 -102
@@ -4,8 +4,9 @@ import { LabContext, type LabContextValue } from '../lab/LabContext';
4
4
  import { noneAdapter } from '../state/adapters';
5
5
  import { LabStoreContext } from '../state/context';
6
6
  import { createLabStore } from '../state/store';
7
- import type { WorkspaceRecord } from '../state/types';
8
- import { WorkspaceChrome } from './WorkspaceChrome';
7
+ import type { TrialRecord } from '../state/types';
8
+ import { DEFAULT_VIEW } from '../state/view';
9
+ import { TrialChrome } from './TrialChrome';
9
10
 
10
11
  const noop = () => {};
11
12
 
@@ -16,7 +17,7 @@ const stub: Instrument = {
16
17
  render: () => <div className="lk-stub-display">stub experiment area</div>,
17
18
  };
18
19
 
19
- const record: WorkspaceRecord = {
20
+ const record: TrialRecord = {
20
21
  id: 'ws-demo',
21
22
  instrumentName: 'Stub',
22
23
  config: {},
@@ -29,12 +30,12 @@ function Harness() {
29
30
  const store = createLabStore({ storageKey: 'sb', storage: noneAdapter });
30
31
  const lab: LabContextValue = {
31
32
  instruments: [stub],
32
- workspaces: [record],
33
- addWorkspace: noop,
34
- cloneWorkspace: noop,
35
- closeWorkspace: noop,
36
- resetWorkspace: noop,
37
- reorderWorkspaces: noop,
33
+ trials: [record],
34
+ addTrial: noop,
35
+ cloneTrial: noop,
36
+ closeTrial: noop,
37
+ resetTrial: noop,
38
+ reorderTrials: noop,
38
39
  savedSnapshots: [],
39
40
  saveSnapshot: noop,
40
41
  loadSnapshot: noop,
@@ -46,21 +47,22 @@ function Harness() {
46
47
  <LabStoreContext.Provider value={{ store }}>
47
48
  <LabContext.Provider value={lab}>
48
49
  <div style={{ height: '500px' }}>
49
- <WorkspaceChrome
50
- workspaceId="ws-demo"
51
- record={record}
52
- instrument={stub}
53
- isLastWorkspace={true}
54
- >
50
+ <TrialChrome trialId="ws-demo" record={record} instrument={stub} isLastTrial={true}>
55
51
  {stub.render({
56
52
  state: {},
57
53
  config: {},
58
54
  setState: () => {},
59
55
  setConfig: () => {},
60
- workspace: { id: 'ws-demo', zoom: 1, setZoom: () => {} },
56
+ trial: {
57
+ id: 'ws-demo',
58
+ view: DEFAULT_VIEW,
59
+ setView: () => {},
60
+ zoom: 1,
61
+ setZoom: () => {},
62
+ },
61
63
  emit: () => {},
62
64
  })}
63
- </WorkspaceChrome>
65
+ </TrialChrome>
64
66
  </div>
65
67
  </LabContext.Provider>
66
68
  </LabStoreContext.Provider>
@@ -68,7 +70,7 @@ function Harness() {
68
70
  }
69
71
 
70
72
  const meta: Meta<typeof Harness> = {
71
- title: 'labkit/Workspace/Workspace',
73
+ title: 'labkit/Trial/Trial',
72
74
  component: Harness,
73
75
  parameters: { layout: 'fullscreen' },
74
76
  };
@@ -6,14 +6,14 @@ 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 { WorkspaceRecord } from '../state/types';
9
+ import type { TrialRecord } from '../state/types';
10
10
  import { DefaultToolbar } from './DefaultToolbar';
11
- import type { WorkspaceToolbarContext } from './slotTypes';
12
- import { WorkspaceChrome } from './WorkspaceChrome';
11
+ import type { TrialToolbarContext } from './slotTypes';
12
+ import { TrialChrome } from './TrialChrome';
13
13
 
14
- function makeCtx(overrides: Partial<WorkspaceToolbarContext> = {}): WorkspaceToolbarContext {
14
+ function makeCtx(overrides: Partial<TrialToolbarContext> = {}): TrialToolbarContext {
15
15
  return {
16
- workspaceId: 'ws-1',
16
+ trialId: 'ws-1',
17
17
  instrumentName: 'Stub',
18
18
  hasUndo: false,
19
19
  canUndo: false,
@@ -32,7 +32,7 @@ function makeCtx(overrides: Partial<WorkspaceToolbarContext> = {}): WorkspaceToo
32
32
  clone: vi.fn(),
33
33
  reset: vi.fn(),
34
34
  close: vi.fn(),
35
- isLastWorkspace: false,
35
+ isLastTrial: false,
36
36
  ...overrides,
37
37
  };
38
38
  }
@@ -43,8 +43,8 @@ describe('<DefaultToolbar>', () => {
43
43
  expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
44
44
  });
45
45
 
46
- it('disables close when isLastWorkspace is true', () => {
47
- render(<DefaultToolbar ctx={makeCtx({ isLastWorkspace: true })} />);
46
+ it('disables close when isLastTrial is true', () => {
47
+ render(<DefaultToolbar ctx={makeCtx({ isLastTrial: true })} />);
48
48
  expect(screen.getByRole('button', { name: /close/i })).toBeDisabled();
49
49
  });
50
50
 
@@ -79,7 +79,7 @@ describe('<DefaultToolbar>', () => {
79
79
  {
80
80
  id: 's1',
81
81
  name: 'First',
82
- workspaceId: 'ws-1',
82
+ trialId: 'ws-1',
83
83
  instrumentName: 'Stub',
84
84
  config: {},
85
85
  state: {},
@@ -99,7 +99,7 @@ const stubInstrument: Instrument = {
99
99
  render: () => null,
100
100
  };
101
101
 
102
- const stubRecord: WorkspaceRecord = {
102
+ const stubRecord: TrialRecord = {
103
103
  id: 'ws-1',
104
104
  instrumentName: 'Stub',
105
105
  config: {},
@@ -108,7 +108,7 @@ const stubRecord: WorkspaceRecord = {
108
108
  undoStack: { past: [], future: [] },
109
109
  };
110
110
 
111
- type ChromeProps = Parameters<typeof WorkspaceChrome>[0];
111
+ type ChromeProps = Parameters<typeof TrialChrome>[0];
112
112
 
113
113
  function ChromeHarness({
114
114
  children,
@@ -121,12 +121,12 @@ function ChromeHarness({
121
121
  });
122
122
  const labCtx: LabContextValue = {
123
123
  instruments: [stubInstrument],
124
- workspaces: [stubRecord],
125
- addWorkspace: vi.fn(),
126
- cloneWorkspace: vi.fn(),
127
- closeWorkspace: vi.fn(),
128
- resetWorkspace: vi.fn(),
129
- reorderWorkspaces: vi.fn(),
124
+ trials: [stubRecord],
125
+ addTrial: vi.fn(),
126
+ cloneTrial: vi.fn(),
127
+ closeTrial: vi.fn(),
128
+ resetTrial: vi.fn(),
129
+ reorderTrials: vi.fn(),
130
130
  savedSnapshots: [],
131
131
  saveSnapshot: vi.fn(),
132
132
  loadSnapshot: vi.fn(),
@@ -138,21 +138,21 @@ function ChromeHarness({
138
138
  return (
139
139
  <LabStoreContext.Provider value={{ store }}>
140
140
  <LabContext.Provider value={labCtx}>
141
- <WorkspaceChrome
142
- workspaceId="ws-1"
141
+ <TrialChrome
142
+ trialId="ws-1"
143
143
  record={stubRecord}
144
144
  instrument={stubInstrument}
145
- isLastWorkspace={false}
145
+ isLastTrial={false}
146
146
  {...props}
147
147
  >
148
148
  {children ?? <div data-testid="content">content</div>}
149
- </WorkspaceChrome>
149
+ </TrialChrome>
150
150
  </LabContext.Provider>
151
151
  </LabStoreContext.Provider>
152
152
  );
153
153
  }
154
154
 
155
- describe('<WorkspaceChrome>', () => {
155
+ describe('<TrialChrome>', () => {
156
156
  it('renders children in the content area', () => {
157
157
  render(<ChromeHarness />);
158
158
  expect(screen.getByTestId('content')).toBeInTheDocument();
@@ -172,16 +172,16 @@ describe('<WorkspaceChrome>', () => {
172
172
  it('Cmd+S triggers saveSnapshot', () => {
173
173
  const saveSnapshot = vi.fn();
174
174
  render(<ChromeHarness labOverrides={{ saveSnapshot }} />);
175
- const region = screen.getByRole('region', { name: /workspace/i });
175
+ const region = screen.getByRole('region', { name: /trial/i });
176
176
  fireEvent.keyDown(region, { key: 's', metaKey: true });
177
177
  expect(saveSnapshot).toHaveBeenCalledWith('ws-1', undefined);
178
178
  });
179
179
  });
180
180
 
181
- describe('<Lab> + WorkspaceChrome integration', () => {
182
- it('Lab provides context for nested WorkspaceChrome', () => {
181
+ describe('<Lab> + TrialChrome integration', () => {
182
+ it('Lab provides context for nested TrialChrome', () => {
183
183
  render(<Lab instruments={[stubInstrument]} defaultInstrument="Stub" />);
184
- // Lab seeds a workspace; no chrome rendered without children — sanity check Lab mounts.
184
+ // Lab seeds a trial; no chrome rendered without children — sanity check Lab mounts.
185
185
  expect(document.querySelector('.lk-lab')).toBeTruthy();
186
186
  });
187
187
  });
@@ -9,64 +9,63 @@ import type {
9
9
  LayerDescriptor,
10
10
  PaletteItem,
11
11
  RenderContext,
12
- ViewTransform,
13
12
  } from '../instrument/types';
13
+ import { useJob } from '../job/useJob';
14
14
  import { useLabContext } from '../lab/LabContext';
15
15
  import { LayerList } from '../layers/LayerList';
16
16
  import { LabStoreContext } from '../state/context';
17
17
  import type { LabStore } from '../state/store';
18
- import type { WorkspaceRecord } from '../state/types';
18
+ import type { TrialRecord } from '../state/types';
19
+ import { as2DView, DEFAULT_VIEW } from '../state/view';
19
20
  import { createEventBus, type EventBus } from '../undo/eventBus';
20
21
  import { pushSnapshot, redo as undoRedo, undo as undoUndo } from '../undo/undoStack';
21
- import type { UndoBindings } from './WorkspaceChrome';
22
- import { WorkspaceChrome } from './WorkspaceChrome';
22
+ import type { UndoBindings } from './TrialChrome';
23
+ import { TrialChrome } from './TrialChrome';
23
24
 
24
- /** Props for `<Workspace>`. */
25
- export interface WorkspaceProps {
25
+ /** Props for `<Trial>`. */
26
+ export interface TrialProps {
26
27
  id: string;
27
28
  }
28
29
 
29
- /** Renders one workspace from the lab store: looks up its record and
30
- * instrument, and mounts the instrument inside the workspace chrome. */
31
- export function Workspace({ id }: WorkspaceProps) {
30
+ /** Renders one trial from the lab store: looks up its record and
31
+ * instrument, and mounts the instrument inside the trial chrome. */
32
+ export function Trial({ id }: TrialProps) {
32
33
  const lab = useLabContext();
33
34
  const storeCtx = useContext(LabStoreContext);
34
- if (!storeCtx) throw new Error('[labkit] <Workspace> requires <LabStoreProvider>');
35
- const record = useStore(storeCtx.store, (s) => s.workspaces.find((w) => w.id === id));
35
+ if (!storeCtx) throw new Error('[labkit] <Trial> requires <LabStoreProvider>');
36
+ const record = useStore(storeCtx.store, (s) => s.trials.find((w) => w.id === id));
36
37
  if (!record) {
37
- return <div className="lk-workspace lk-workspace--unknown">Workspace not found: {id}</div>;
38
+ return <div className="lk-trial lk-trial--unknown">Trial not found: {id}</div>;
38
39
  }
39
40
  const instrument = lab.instruments.find((i) => i.name === record.instrumentName);
40
41
  if (!instrument) {
41
42
  return (
42
- <div className="lk-workspace lk-workspace--unknown">
43
- Unknown instrument: {record.instrumentName}
44
- </div>
43
+ <div className="lk-trial lk-trial--unknown">Unknown instrument: {record.instrumentName}</div>
45
44
  );
46
45
  }
47
46
  return (
48
- <WorkspaceRuntime
47
+ <TrialRuntime
49
48
  record={record}
50
49
  instrument={instrument}
51
50
  store={storeCtx.store}
52
- isLast={lab.workspaces.length <= 1}
51
+ isLast={lab.trials.length <= 1}
53
52
  />
54
53
  );
55
54
  }
56
55
 
57
- interface WorkspaceRuntimeProps {
58
- record: WorkspaceRecord;
56
+ interface TrialRuntimeProps {
57
+ record: TrialRecord;
59
58
  instrument: Instrument;
60
59
  store: LabStore;
61
60
  isLast: boolean;
62
61
  }
63
62
 
64
- function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntimeProps) {
63
+ function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps) {
65
64
  const canvasContainerRef = useRef<HTMLDivElement | null>(null);
66
- const updateWorkspaceState = useStore(store, (s) => s.updateWorkspaceState);
67
- const updateWorkspaceConfig = useStore(store, (s) => s.updateWorkspaceConfig);
68
- const updateWorkspaceView = useStore(store, (s) => s.updateWorkspaceView);
69
- const updateWorkspaceUndoStack = useStore(store, (s) => s.updateWorkspaceUndoStack);
65
+ const updateTrialState = useStore(store, (s) => s.updateTrialState);
66
+ const updateTrialConfig = useStore(store, (s) => s.updateTrialConfig);
67
+ const updateTrialView = useStore(store, (s) => s.updateTrialView);
68
+ const updateTrialUndoStack = useStore(store, (s) => s.updateTrialUndoStack);
70
69
 
71
70
  const busRef = useRef<EventBus | null>(null);
72
71
  if (busRef.current === null) busRef.current = createEventBus();
@@ -81,39 +80,59 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
81
80
 
82
81
  const snapshotIfNeeded = (event: string): void => {
83
82
  if (!undoCap || !undoEvents.has(event)) return;
84
- const current = store.getState().workspaces.find((w) => w.id === record.id);
83
+ const current = store.getState().trials.find((w) => w.id === record.id);
85
84
  if (!current) return;
86
85
  const snap = structuredClone(current.state);
87
- updateWorkspaceUndoStack(record.id, (prev) => pushSnapshot(prev, snap, maxDepth));
86
+ updateTrialUndoStack(record.id, (prev) => pushSnapshot(prev, snap, maxDepth));
88
87
  };
89
88
 
90
- const setView = (v: ViewTransform): void => updateWorkspaceView(record.id, v);
89
+ const setView = (v: unknown): void => updateTrialView(record.id, v);
90
+
91
+ // `CanvasStack` and the zoom controls are 2D; a trial holding another view shape
92
+ // gets the default here and simply never renders them.
93
+ const view2d = as2DView(record.view);
94
+
95
+ const jobCap = instrument.job;
96
+ // Hooks cannot be conditional, so a trial without the capability still calls
97
+ // this — with a runner that yields nothing and is never started.
98
+ const job = useJob({
99
+ capability: jobCap ?? { run: async function* () {}, onItem: (_i, st) => st },
100
+ config: record.config,
101
+ state: record.state,
102
+ setState: (next) => updateTrialState(record.id, next as never),
103
+ });
91
104
 
92
105
  const renderCtx: RenderContext<unknown, unknown> = {
93
106
  state: record.state,
94
107
  config: record.config,
95
108
  setState: (next) => {
96
109
  snapshotIfNeeded('state.change');
97
- updateWorkspaceState(record.id, next);
110
+ updateTrialState(record.id, next);
98
111
  bus.emit('state.change');
99
112
  },
100
113
  setConfig: (key, value) => {
101
114
  const evt = `config.change:${String(key)}`;
102
115
  snapshotIfNeeded('config.change');
103
116
  snapshotIfNeeded(evt);
104
- updateWorkspaceConfig(record.id, key as never, value as never);
117
+ updateTrialConfig(record.id, key as never, value as never);
105
118
  bus.emit('config.change');
106
119
  bus.emit(evt);
107
120
  },
108
- workspace: {
121
+ trial: {
109
122
  id: record.id,
110
- zoom: record.view.zoom,
111
- setZoom: (z) => updateWorkspaceView(record.id, { ...record.view, zoom: z }),
123
+ view: record.view,
124
+ setView,
125
+ zoom: view2d?.zoom ?? 1,
126
+ setZoom: (z) => {
127
+ if (!view2d) return;
128
+ updateTrialView(record.id, { ...view2d, zoom: z });
129
+ },
112
130
  },
113
131
  emit: (event) => {
114
132
  snapshotIfNeeded(event);
115
133
  bus.emit(event);
116
134
  },
135
+ job: jobCap ? job : undefined,
117
136
  };
118
137
 
119
138
  const undoBindings: UndoBindings | undefined = undoCap
@@ -123,14 +142,14 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
123
142
  undo: () => {
124
143
  const result = undoUndo(record.undoStack, structuredClone(record.state));
125
144
  if (!result) return;
126
- updateWorkspaceState(record.id, result.snapshot as never);
127
- updateWorkspaceUndoStack(record.id, result.stack);
145
+ updateTrialState(record.id, result.snapshot as never);
146
+ updateTrialUndoStack(record.id, result.stack);
128
147
  },
129
148
  redo: () => {
130
149
  const result = undoRedo(record.undoStack, structuredClone(record.state));
131
150
  if (!result) return;
132
- updateWorkspaceState(record.id, result.snapshot as never);
133
- updateWorkspaceUndoStack(record.id, result.stack);
151
+ updateTrialState(record.id, result.snapshot as never);
152
+ updateTrialUndoStack(record.id, result.stack);
134
153
  },
135
154
  }
136
155
  : undefined;
@@ -144,8 +163,13 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
144
163
  return ordered.map((layer) => ({
145
164
  id: layer.id,
146
165
  visible: layerVisibility[layer.id] !== false,
147
- render: (ctx, view) =>
148
- layer.draw(ctx, { state: record.state, config: record.config, zoom: view.zoom }),
166
+ render: (ctx, view) => {
167
+ // Camera applied here, so a layer draws in world coordinates. `zoom`
168
+ // stays in the args for line widths, which must not scale with it.
169
+ ctx.translate(view.pan.x, view.pan.y);
170
+ ctx.scale(view.zoom, view.zoom);
171
+ layer.draw(ctx, { state: record.state, config: record.config, zoom: view.zoom });
172
+ },
149
173
  }));
150
174
  }, [instrument.canvas, record.state, record.config, layerVisibility, layerOrder]);
151
175
 
@@ -157,12 +181,12 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
157
181
  const dragDropResult = useDragDrop({
158
182
  capability: instrument.dragDrop ?? { palette: [], onDrop: (_p, _i, s) => s },
159
183
  canvasContainerRef,
160
- view: record.view,
184
+ view: view2d ?? DEFAULT_VIEW,
161
185
  state: record.state,
162
186
  config: record.config,
163
187
  setState: (next) => {
164
188
  snapshotIfNeeded('canvas.itemAdded');
165
- updateWorkspaceState(record.id, next as never);
189
+ updateTrialState(record.id, next as never);
166
190
  },
167
191
  emit: (evt) => {
168
192
  snapshotIfNeeded(evt);
@@ -206,10 +230,16 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
206
230
  body = (
207
231
  <div
208
232
  ref={canvasContainerRef}
209
- className="lk-workspace__canvas-host"
233
+ className="lk-trial__canvas-host"
210
234
  style={{ width: '100%', height: '100%', position: 'relative' }}
211
235
  >
212
- <CanvasStack layers={layersWithFeedback} view={record.view} onViewChange={setView} />
236
+ <CanvasStack
237
+ layers={layersWithFeedback}
238
+ view={view2d ?? DEFAULT_VIEW}
239
+ onViewChange={setView}
240
+ >
241
+ {instrument.render(renderCtx)}
242
+ </CanvasStack>
213
243
  <DragOverlay drag={dragDropResult.drag} />
214
244
  </div>
215
245
  );
@@ -239,11 +269,12 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
239
269
  ) : null;
240
270
 
241
271
  return (
242
- <WorkspaceChrome
243
- workspaceId={record.id}
272
+ <TrialChrome
273
+ job={jobCap ? job : undefined}
274
+ trialId={record.id}
244
275
  record={record}
245
276
  instrument={instrument}
246
- isLastWorkspace={isLast}
277
+ isLastTrial={isLast}
247
278
  undoBindings={undoBindings}
248
279
  sidebarExtras={
249
280
  <>
@@ -253,6 +284,6 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
253
284
  }
254
285
  >
255
286
  {body}
256
- </WorkspaceChrome>
287
+ </TrialChrome>
257
288
  );
258
289
  }
@@ -1,9 +1,11 @@
1
1
  import { type KeyboardEvent, type ReactNode, useContext, useMemo } from 'react';
2
2
  import { useStore } from 'zustand/react';
3
3
  import type { Instrument } from '../instrument/types';
4
+ import type { JobHandle } from '../job/types';
4
5
  import { useLabContext } from '../lab/LabContext';
5
6
  import { LabStoreContext } from '../state/context';
6
- import type { WorkspaceRecord } from '../state/types';
7
+ import type { TrialRecord } from '../state/types';
8
+ import { as2DView } from '../state/view';
7
9
  import { DefaultSidebar } from './DefaultSidebar';
8
10
  import { DefaultStatusBar } from './DefaultStatusBar';
9
11
  import { DefaultToolbar } from './DefaultToolbar';
@@ -11,9 +13,9 @@ import type {
11
13
  SidebarSlot,
12
14
  StatusBarSlot,
13
15
  ToolbarSlot,
14
- WorkspaceSidebarContext,
15
- WorkspaceStatusBarContext,
16
- WorkspaceToolbarContext,
16
+ TrialSidebarContext,
17
+ TrialStatusBarContext,
18
+ TrialToolbarContext,
17
19
  } from './slotTypes';
18
20
 
19
21
  export interface UndoBindings {
@@ -23,16 +25,18 @@ export interface UndoBindings {
23
25
  redo: () => void;
24
26
  }
25
27
 
26
- /** Props for `<WorkspaceChrome>`. */
27
- export interface WorkspaceChromeProps {
28
- workspaceId: string;
29
- record: WorkspaceRecord;
28
+ /** Props for `<TrialChrome>`. */
29
+ export interface TrialChromeProps {
30
+ trialId: string;
31
+ record: TrialRecord;
30
32
  instrument: Instrument;
31
- isLastWorkspace: boolean;
33
+ isLastTrial: boolean;
32
34
  toolbar?: ToolbarSlot;
33
35
  sidebar?: SidebarSlot;
34
36
  statusBar?: StatusBarSlot;
35
37
  undoBindings?: UndoBindings;
38
+ /** Supplied when the instrument declares a `job`. */
39
+ job?: JobHandle;
36
40
  sidebarExtras?: ReactNode;
37
41
  children: ReactNode;
38
42
  }
@@ -40,56 +44,62 @@ export interface WorkspaceChromeProps {
40
44
  /** The frame around a running instrument — toolbar, sidebar, status bar —
41
45
  * each replaceable by a slot. Assembles the slot contexts and wires the undo
42
46
  * keyboard shortcuts. */
43
- export function WorkspaceChrome({
44
- workspaceId,
47
+ export function TrialChrome({
48
+ trialId,
45
49
  record,
46
50
  instrument,
47
- isLastWorkspace,
51
+ isLastTrial,
48
52
  toolbar,
49
53
  sidebar,
50
54
  statusBar,
51
55
  undoBindings,
56
+ job,
52
57
  sidebarExtras,
53
58
  children,
54
- }: WorkspaceChromeProps) {
59
+ }: TrialChromeProps) {
55
60
  const lab = useLabContext();
56
61
  const storeCtx = useContext(LabStoreContext);
57
- if (!storeCtx) throw new Error('[labkit] WorkspaceChrome requires <LabStoreProvider>');
58
- const updateWorkspaceView = useStore(storeCtx.store, (s) => s.updateWorkspaceView);
59
- const updateWorkspaceConfig = useStore(storeCtx.store, (s) => s.updateWorkspaceConfig);
60
- const updateWorkspaceState = useStore(storeCtx.store, (s) => s.updateWorkspaceState);
62
+ if (!storeCtx) throw new Error('[labkit] TrialChrome requires <LabStoreProvider>');
63
+ const updateTrialView = useStore(storeCtx.store, (s) => s.updateTrialView);
64
+ const updateTrialConfig = useStore(storeCtx.store, (s) => s.updateTrialConfig);
65
+ const updateTrialState = useStore(storeCtx.store, (s) => s.updateTrialState);
61
66
 
62
- const toolbarCtx = useMemo<WorkspaceToolbarContext>(() => {
67
+ // The trial view is opaque to labkit, so the zoom chrome asks for the 2D shape
68
+ // and goes inert when the trial holds something else — an orbit, say.
69
+ const view2d = as2DView(record.view);
70
+
71
+ const toolbarCtx = useMemo<TrialToolbarContext>(() => {
63
72
  const setZoom = (z: number): void => {
64
- updateWorkspaceView(workspaceId, { ...record.view, zoom: z });
73
+ if (!view2d) return;
74
+ updateTrialView(trialId, { ...view2d, zoom: z });
65
75
  };
66
76
  return {
67
- workspaceId,
77
+ trialId,
68
78
  instrumentName: record.instrumentName,
69
79
  hasUndo: instrument.undo != null,
70
80
  canUndo: undoBindings?.canUndo ?? false,
71
81
  canRedo: undoBindings?.canRedo ?? false,
72
82
  undo: undoBindings?.undo ?? (() => {}),
73
83
  redo: undoBindings?.redo ?? (() => {}),
74
- zoom: record.view.zoom,
84
+ zoom: view2d?.zoom ?? 1,
75
85
  setZoom,
76
- zoomIn: () => setZoom(record.view.zoom * 1.25),
77
- zoomOut: () => setZoom(record.view.zoom * 0.8),
86
+ zoomIn: () => setZoom((view2d?.zoom ?? 1) * 1.25),
87
+ zoomOut: () => setZoom((view2d?.zoom ?? 1) * 0.8),
78
88
  resetZoom: () => setZoom(1),
79
89
  hasCanvas: instrument.canvas != null,
80
- savedSnapshots: lab.savedSnapshots.filter((s) => s.workspaceId === workspaceId),
81
- saveSnapshot: (name) => lab.saveSnapshot(workspaceId, name),
82
- loadSnapshot: (snapshotId) => lab.loadSnapshot(workspaceId, snapshotId),
83
- clone: () => lab.cloneWorkspace(workspaceId),
84
- reset: () => lab.resetWorkspace(workspaceId),
85
- close: () => lab.closeWorkspace(workspaceId),
86
- isLastWorkspace,
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,
87
97
  };
88
- }, [workspaceId, record, instrument, lab, isLastWorkspace, updateWorkspaceView, undoBindings]);
98
+ }, [trialId, record, instrument, lab, isLastTrial, updateTrialView, undoBindings, view2d]);
89
99
 
90
- const sidebarCtx = useMemo<WorkspaceSidebarContext>(
100
+ const sidebarCtx = useMemo<TrialSidebarContext>(
91
101
  () => ({
92
- workspaceId,
102
+ trialId,
93
103
  instrumentName: record.instrumentName,
94
104
  configFields: instrument.configSchema?.() ?? [],
95
105
  config: record.config,
@@ -100,21 +110,21 @@ export function WorkspaceChrome({
100
110
  `[labkit] setConfig: unknown key "${key}" for instrument "${record.instrumentName}"`,
101
111
  );
102
112
  }
103
- updateWorkspaceConfig(workspaceId, key as never, value as never);
113
+ updateTrialConfig(trialId, key as never, value as never);
104
114
  if (instrument.onConfigChange) {
105
115
  const nextConfig = { ...prevConfig, [key]: value };
106
116
  const nextState = instrument.onConfigChange(nextConfig, prevConfig, record.state);
107
- updateWorkspaceState(workspaceId, nextState as never);
117
+ updateTrialState(trialId, nextState as never);
108
118
  }
109
119
  },
110
120
  }),
111
- [workspaceId, record, instrument, updateWorkspaceConfig, updateWorkspaceState],
121
+ [trialId, record, instrument, updateTrialConfig, updateTrialState],
112
122
  );
113
123
 
114
- const statusCtx: WorkspaceStatusBarContext = {
115
- workspaceId,
124
+ const statusCtx: TrialStatusBarContext = {
125
+ trialId,
116
126
  instrumentName: record.instrumentName,
117
- zoom: record.view.zoom,
127
+ zoom: view2d ? view2d.zoom : null,
118
128
  };
119
129
 
120
130
  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>): void => {
@@ -132,21 +142,38 @@ export function WorkspaceChrome({
132
142
 
133
143
  return (
134
144
  <section
135
- className="lk-workspace"
136
- aria-label={`Workspace ${record.instrumentName}`}
145
+ className="lk-trial"
146
+ aria-label={`Trial ${record.instrumentName}`}
137
147
  onKeyDown={handleKeyDown}
138
148
  >
139
- <div className="lk-workspace__toolbar">
149
+ <div className="lk-trial__toolbar">
140
150
  {toolbar ? toolbar(toolbarCtx) : <DefaultToolbar ctx={toolbarCtx} />}
141
151
  </div>
142
- <div className="lk-workspace__body">
143
- <div className="lk-workspace__sidebar">
152
+ <div className="lk-trial__body">
153
+ <div className="lk-trial__sidebar">
144
154
  {sidebar ? sidebar(sidebarCtx) : <DefaultSidebar ctx={sidebarCtx} />}
145
155
  {sidebarExtras}
146
156
  </div>
147
- <div className="lk-workspace__content">{children}</div>
157
+ <div className="lk-trial__content">{children}</div>
148
158
  </div>
149
- <div className="lk-workspace__status">
159
+ <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}
150
177
  {statusBar ? statusBar(statusCtx) : <DefaultStatusBar ctx={statusCtx} />}
151
178
  </div>
152
179
  </section>