@weasel-js/labkit 1.0.3 → 1.1.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 (94) hide show
  1. package/README.md +6 -6
  2. package/dist/_dts/{DrawCommand-uKHt4Vul.d.ts → DrawCommand-BkZztJsW.d.ts} +3 -1
  3. package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
  4. package/dist/_dts/{useExperimentState-vrttakTt.d.ts → useTrialState-DYe2vUwN.d.ts} +62 -34
  5. package/dist/canvas/index.d.ts +1 -1
  6. package/dist/chunk-3TYUJR7Z.js +488 -0
  7. package/dist/chunk-3TYUJR7Z.js.map +1 -0
  8. package/dist/{chunk-N5KTQKQA.js → chunk-C6GJKPUI.js} +835 -770
  9. package/dist/chunk-C6GJKPUI.js.map +1 -0
  10. package/dist/{chunk-3WPOGKUP.js → chunk-DJLDIRFN.js} +543 -226
  11. package/dist/chunk-DJLDIRFN.js.map +1 -0
  12. package/dist/{chunk-73PXCRCR.js → chunk-NRKWVTVT.js} +6 -6
  13. package/dist/chunk-NRKWVTVT.js.map +1 -0
  14. package/dist/dragdrop/index.d.ts +1 -1
  15. package/dist/index.d.ts +197 -148
  16. package/dist/index.js +275 -161
  17. package/dist/index.js.map +1 -1
  18. package/dist/layers/index.d.ts +2 -2
  19. package/dist/passthrough/weasel-canvas.d.ts +1 -1
  20. package/dist/passthrough/weasel-canvas.js +1 -1
  21. package/dist/passthrough/weasel-ui.d.ts +10 -3
  22. package/dist/passthrough/weasel-ui.js +2 -2
  23. package/dist/state/index.d.ts +14 -14
  24. package/dist/state/index.js +5 -5
  25. package/dist/state/index.js.map +1 -1
  26. package/dist/styles.css +179 -14
  27. package/dist/ui/layers/index.js +3 -3
  28. package/dist/undo/index.d.ts +1 -1
  29. package/package.json +2 -1
  30. package/src/canvas/AGENTS.md +6 -2
  31. package/src/dragdrop/dragDrop.test.tsx +4 -7
  32. package/src/index.test.ts +52 -0
  33. package/src/index.ts +11 -7
  34. package/src/instrument/SineWave.smoke.test.tsx +1 -1
  35. package/src/instrument/capabilityDetector.ts +1 -1
  36. package/src/instrument/types.ts +18 -6
  37. package/src/lab/Lab.less +16 -0
  38. package/src/lab/Lab.stories.tsx +5 -5
  39. package/src/lab/Lab.test.tsx +25 -25
  40. package/src/lab/Lab.tsx +56 -44
  41. package/src/lab/LabContext.ts +12 -11
  42. package/src/lab/LabShell.tsx +1 -1
  43. package/src/lab/Workspace.less +40 -0
  44. package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +27 -11
  45. package/src/lab/Workspace.test.tsx +114 -0
  46. package/src/lab/Workspace.tsx +196 -0
  47. package/src/lab/index.ts +2 -4
  48. package/src/layers/AGENTS.md +3 -3
  49. package/src/primitives/Toolbar.stories.tsx +2 -2
  50. package/src/state/SingletonExperiment.test.tsx +8 -8
  51. package/src/state/SingletonExperiment.tsx +9 -9
  52. package/src/state/context.tsx +12 -12
  53. package/src/state/document.test.ts +369 -0
  54. package/src/state/document.ts +194 -0
  55. package/src/state/helpers.test.ts +74 -46
  56. package/src/state/helpers.ts +23 -31
  57. package/src/state/index.ts +16 -8
  58. package/src/state/store.test.ts +440 -40
  59. package/src/state/store.ts +166 -90
  60. package/src/state/types.ts +30 -10
  61. package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
  62. package/src/state/useTrialState.ts +29 -0
  63. package/src/styles.less +2 -2
  64. package/src/test-setup.ts +19 -0
  65. package/src/theme/base.less +1 -1
  66. package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
  67. package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
  68. package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
  69. package/src/trial/Trial.canvas.test.tsx +80 -0
  70. package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
  71. package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -17
  72. package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -25
  73. package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
  74. package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
  75. package/src/{workspace → trial}/index.ts +13 -12
  76. package/src/{workspace → trial}/slotTypes.ts +16 -16
  77. package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +60 -33
  78. package/src/trial/trialOps.ts +99 -0
  79. package/src/ui/format.test.ts +33 -0
  80. package/src/ui/format.ts +11 -0
  81. package/src/ui/properties/CurveField.tsx +6 -6
  82. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
  83. package/dist/chunk-3WPOGKUP.js.map +0 -1
  84. package/dist/chunk-73PXCRCR.js.map +0 -1
  85. package/dist/chunk-N5KTQKQA.js.map +0 -1
  86. package/dist/chunk-T7OKNJTY.js +0 -351
  87. package/dist/chunk-T7OKNJTY.js.map +0 -1
  88. package/src/lab/WorkspaceGrid.less +0 -8
  89. package/src/lab/WorkspaceGrid.test.tsx +0 -40
  90. package/src/lab/WorkspaceGrid.tsx +0 -22
  91. package/src/lab/gridDims.test.ts +0 -35
  92. package/src/lab/gridDims.ts +0 -13
  93. package/src/state/useExperimentState.ts +0 -31
  94. package/src/workspace/workspaceOps.ts +0 -86
@@ -1,13 +1,13 @@
1
1
  import { ControlPanel } from '../controls/ControlPanel';
2
2
  import { Sidebar } from '../primitives/Sidebar';
3
- import type { WorkspaceSidebarContext } from './slotTypes';
3
+ import type { TrialSidebarContext } from './slotTypes';
4
4
 
5
5
  /** Props for `<DefaultSidebar>`. */
6
6
  export interface DefaultSidebarProps {
7
- ctx: WorkspaceSidebarContext;
7
+ ctx: TrialSidebarContext;
8
8
  }
9
9
 
10
- /** The sidebar a workspace renders when no `sidebar` slot is supplied: a
10
+ /** The sidebar a trial renders when no `sidebar` slot is supplied: a
11
11
  * control panel over the instrument's config schema. */
12
12
  export function DefaultSidebar({ ctx }: DefaultSidebarProps) {
13
13
  if (ctx.configFields.length === 0) {
@@ -1,18 +1,19 @@
1
1
  import { StatusBar } from '../primitives/StatusBar';
2
- import type { WorkspaceStatusBarContext } from './slotTypes';
2
+ import { formatZoom } from '../ui/format';
3
+ import type { TrialStatusBarContext } from './slotTypes';
3
4
 
4
5
  /** Props for `<DefaultStatusBar>`. */
5
6
  export interface DefaultStatusBarProps {
6
- ctx: WorkspaceStatusBarContext;
7
+ ctx: TrialStatusBarContext;
7
8
  }
8
9
 
9
- /** The status bar a workspace renders when no `statusBar` slot is supplied:
10
+ /** The status bar a trial renders when no `statusBar` slot is supplied:
10
11
  * the instrument's name and the current zoom. */
11
12
  export function DefaultStatusBar({ ctx }: DefaultStatusBarProps) {
12
13
  return (
13
14
  <StatusBar>
14
15
  <StatusBar.Section>{ctx.instrumentName}</StatusBar.Section>
15
- <StatusBar.Section>{Math.round(ctx.zoom * 100)}%</StatusBar.Section>
16
+ <StatusBar.Section>{formatZoom(ctx.zoom)}</StatusBar.Section>
16
17
  </StatusBar>
17
18
  );
18
19
  }
@@ -1,12 +1,13 @@
1
1
  import { Toolbar } from '../primitives/Toolbar';
2
- import type { WorkspaceToolbarContext } from './slotTypes';
2
+ import { formatZoom } from '../ui/format';
3
+ import type { TrialToolbarContext } from './slotTypes';
3
4
 
4
5
  /** Props for `<DefaultToolbar>`. */
5
6
  export interface DefaultToolbarProps {
6
- ctx: WorkspaceToolbarContext;
7
+ ctx: TrialToolbarContext;
7
8
  }
8
9
 
9
- /** The toolbar a workspace renders when no `toolbar` slot is supplied. Only
10
+ /** The toolbar a trial renders when no `toolbar` slot is supplied. Only
10
11
  * shows the controls the instrument's declared capabilities support. */
11
12
  export function DefaultToolbar({ ctx }: DefaultToolbarProps) {
12
13
  return (
@@ -32,7 +33,7 @@ export function DefaultToolbar({ ctx }: DefaultToolbarProps) {
32
33
  <Toolbar.Button onClick={ctx.zoomOut} title="Zoom out">
33
34
 
34
35
  </Toolbar.Button>
35
- <span className="lk-toolbar__zoom-label">{Math.round(ctx.zoom * 100)}%</span>
36
+ <span className="lk-toolbar__zoom-label">{formatZoom(ctx.zoom)}</span>
36
37
  <Toolbar.Button onClick={ctx.zoomIn} title="Zoom in">
37
38
  +
38
39
  </Toolbar.Button>
@@ -73,16 +74,16 @@ export function DefaultToolbar({ ctx }: DefaultToolbarProps) {
73
74
 
74
75
  <Toolbar.Spacer />
75
76
 
76
- <Toolbar.Button onClick={ctx.clone} title="Clone workspace">
77
+ <Toolbar.Button onClick={ctx.clone} title="Clone trial">
77
78
  Clone
78
79
  </Toolbar.Button>
79
- <Toolbar.Button onClick={ctx.reset} title="Reset workspace">
80
+ <Toolbar.Button onClick={ctx.reset} title="Reset trial">
80
81
  Reset
81
82
  </Toolbar.Button>
82
83
  <Toolbar.Button
83
84
  onClick={ctx.close}
84
- disabled={ctx.isLastWorkspace}
85
- title={ctx.isLastWorkspace ? 'Cannot close the last workspace' : 'Close workspace'}
85
+ disabled={ctx.isLastTrial}
86
+ title={ctx.isLastTrial ? 'Cannot close the last trial' : 'Close trial'}
86
87
  >
87
88
  Close
88
89
  </Toolbar.Button>
@@ -0,0 +1,80 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
3
+ import { defineInstrument } from '../instrument/defineInstrument';
4
+ import { Lab } from '../lab/Lab';
5
+
6
+ interface St {
7
+ n: number;
8
+ }
9
+
10
+ const ctxSpies = {
11
+ save: vi.fn(),
12
+ restore: vi.fn(),
13
+ setTransform: vi.fn(),
14
+ clearRect: vi.fn(),
15
+ translate: vi.fn(),
16
+ scale: vi.fn(),
17
+ };
18
+
19
+ beforeAll(() => {
20
+ HTMLCanvasElement.prototype.getContext = vi.fn(
21
+ () => ctxSpies,
22
+ ) as unknown as HTMLCanvasElement['getContext'];
23
+ });
24
+
25
+ function makeInstrument(draw: (ctx: CanvasRenderingContext2D, args: unknown) => void) {
26
+ return defineInstrument<St, Record<string, never>>({
27
+ name: 'Probe',
28
+ defaultConfig: () => ({}),
29
+ initialState: () => ({ n: 7 }),
30
+ render: ({ state }) => <output data-testid="readout">n = {state.n}</output>,
31
+ canvas: {
32
+ initialView: { zoom: 2, pan: { x: 30, y: 40 } },
33
+ layers: [{ id: 'main', draw }],
34
+ },
35
+ });
36
+ }
37
+
38
+ describe('an instrument that draws', () => {
39
+ it('still renders its own DOM, as an overlay over the canvas', () => {
40
+ const { container } = render(
41
+ <Lab instruments={[makeInstrument(() => undefined)]} defaultInstrument="Probe" />,
42
+ );
43
+ expect(screen.getByTestId('readout')).toHaveTextContent('n = 7');
44
+ expect(container.querySelector('.lk-canvas-stack__overlay')).toContainElement(
45
+ screen.getByTestId('readout'),
46
+ );
47
+ expect(container.querySelectorAll('canvas').length).toBeGreaterThan(0);
48
+ });
49
+
50
+ it('draws with the camera applied, so world geometry lands where the view says', async () => {
51
+ const draw = vi.fn();
52
+ render(<Lab instruments={[makeInstrument(draw)]} defaultInstrument="Probe" />);
53
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
54
+
55
+ expect(draw).toHaveBeenCalled();
56
+ expect(ctxSpies.translate).toHaveBeenCalledWith(30, 40);
57
+ expect(ctxSpies.scale).toHaveBeenCalledWith(2, 2);
58
+ });
59
+
60
+ it('passes zoom in the args so a layer can keep line widths unscaled', async () => {
61
+ const draw = vi.fn();
62
+ render(<Lab instruments={[makeInstrument(draw)]} defaultInstrument="Probe" />);
63
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
64
+
65
+ expect(draw).toHaveBeenCalledWith(
66
+ expect.anything(),
67
+ expect.objectContaining({ zoom: 2, state: { n: 7 } }),
68
+ );
69
+ });
70
+ });
71
+
72
+ describe('LabProps.instruments', () => {
73
+ it('accepts a typed instrument with no cast', () => {
74
+ // The assignment is the assertion: this file fails to compile if
75
+ // `defineInstrument<St, ...>`'s result stops being assignable.
76
+ const typed = makeInstrument(() => undefined);
77
+ render(<Lab instruments={[typed]} defaultInstrument="Probe" />);
78
+ expect(screen.getByTestId('readout')).toBeInTheDocument();
79
+ });
80
+ });
@@ -1,4 +1,4 @@
1
- .lk-workspace {
1
+ .lk-trial {
2
2
  display: flex;
3
3
  flex-direction: column;
4
4
  height: 100%;
@@ -4,8 +4,8 @@ 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 { TrialChrome } from './TrialChrome';
9
9
 
10
10
  const noop = () => {};
11
11
 
@@ -16,7 +16,7 @@ const stub: Instrument = {
16
16
  render: () => <div className="lk-stub-display">stub experiment area</div>,
17
17
  };
18
18
 
19
- const record: WorkspaceRecord = {
19
+ const record: TrialRecord = {
20
20
  id: 'ws-demo',
21
21
  instrumentName: 'Stub',
22
22
  config: {},
@@ -29,11 +29,12 @@ function Harness() {
29
29
  const store = createLabStore({ storageKey: 'sb', storage: noneAdapter });
30
30
  const lab: LabContextValue = {
31
31
  instruments: [stub],
32
- workspaces: [record],
33
- addWorkspace: noop,
34
- cloneWorkspace: noop,
35
- closeWorkspace: noop,
36
- resetWorkspace: noop,
32
+ trials: [record],
33
+ addTrial: noop,
34
+ cloneTrial: noop,
35
+ closeTrial: noop,
36
+ resetTrial: noop,
37
+ reorderTrials: noop,
37
38
  savedSnapshots: [],
38
39
  saveSnapshot: noop,
39
40
  loadSnapshot: noop,
@@ -45,21 +46,16 @@ function Harness() {
45
46
  <LabStoreContext.Provider value={{ store }}>
46
47
  <LabContext.Provider value={lab}>
47
48
  <div style={{ height: '500px' }}>
48
- <WorkspaceChrome
49
- workspaceId="ws-demo"
50
- record={record}
51
- instrument={stub}
52
- isLastWorkspace={true}
53
- >
49
+ <TrialChrome trialId="ws-demo" record={record} instrument={stub} isLastTrial={true}>
54
50
  {stub.render({
55
51
  state: {},
56
52
  config: {},
57
53
  setState: () => {},
58
54
  setConfig: () => {},
59
- workspace: { id: 'ws-demo', zoom: 1, setZoom: () => {} },
55
+ trial: { id: 'ws-demo', zoom: 1, setZoom: () => {} },
60
56
  emit: () => {},
61
57
  })}
62
- </WorkspaceChrome>
58
+ </TrialChrome>
63
59
  </div>
64
60
  </LabContext.Provider>
65
61
  </LabStoreContext.Provider>
@@ -67,7 +63,7 @@ function Harness() {
67
63
  }
68
64
 
69
65
  const meta: Meta<typeof Harness> = {
70
- title: 'labkit/Workspace/Workspace',
66
+ title: 'labkit/Trial/Trial',
71
67
  component: Harness,
72
68
  parameters: { layout: 'fullscreen' },
73
69
  };
@@ -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,11 +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(),
124
+ trials: [stubRecord],
125
+ addTrial: vi.fn(),
126
+ cloneTrial: vi.fn(),
127
+ closeTrial: vi.fn(),
128
+ resetTrial: vi.fn(),
129
+ reorderTrials: vi.fn(),
129
130
  savedSnapshots: [],
130
131
  saveSnapshot: vi.fn(),
131
132
  loadSnapshot: vi.fn(),
@@ -137,21 +138,21 @@ function ChromeHarness({
137
138
  return (
138
139
  <LabStoreContext.Provider value={{ store }}>
139
140
  <LabContext.Provider value={labCtx}>
140
- <WorkspaceChrome
141
- workspaceId="ws-1"
141
+ <TrialChrome
142
+ trialId="ws-1"
142
143
  record={stubRecord}
143
144
  instrument={stubInstrument}
144
- isLastWorkspace={false}
145
+ isLastTrial={false}
145
146
  {...props}
146
147
  >
147
148
  {children ?? <div data-testid="content">content</div>}
148
- </WorkspaceChrome>
149
+ </TrialChrome>
149
150
  </LabContext.Provider>
150
151
  </LabStoreContext.Provider>
151
152
  );
152
153
  }
153
154
 
154
- describe('<WorkspaceChrome>', () => {
155
+ describe('<TrialChrome>', () => {
155
156
  it('renders children in the content area', () => {
156
157
  render(<ChromeHarness />);
157
158
  expect(screen.getByTestId('content')).toBeInTheDocument();
@@ -171,16 +172,16 @@ describe('<WorkspaceChrome>', () => {
171
172
  it('Cmd+S triggers saveSnapshot', () => {
172
173
  const saveSnapshot = vi.fn();
173
174
  render(<ChromeHarness labOverrides={{ saveSnapshot }} />);
174
- const region = screen.getByRole('region', { name: /workspace/i });
175
+ const region = screen.getByRole('region', { name: /trial/i });
175
176
  fireEvent.keyDown(region, { key: 's', metaKey: true });
176
177
  expect(saveSnapshot).toHaveBeenCalledWith('ws-1', undefined);
177
178
  });
178
179
  });
179
180
 
180
- describe('<Lab> + WorkspaceChrome integration', () => {
181
- it('Lab provides context for nested WorkspaceChrome', () => {
181
+ describe('<Lab> + TrialChrome integration', () => {
182
+ it('Lab provides context for nested TrialChrome', () => {
182
183
  render(<Lab instruments={[stubInstrument]} defaultInstrument="Stub" />);
183
- // 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.
184
185
  expect(document.querySelector('.lk-lab')).toBeTruthy();
185
186
  });
186
187
  });
@@ -15,58 +15,56 @@ 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
19
  import { createEventBus, type EventBus } from '../undo/eventBus';
20
20
  import { pushSnapshot, redo as undoRedo, undo as undoUndo } from '../undo/undoStack';
21
- import type { UndoBindings } from './WorkspaceChrome';
22
- import { WorkspaceChrome } from './WorkspaceChrome';
21
+ import type { UndoBindings } from './TrialChrome';
22
+ import { TrialChrome } from './TrialChrome';
23
23
 
24
- /** Props for `<Workspace>`. */
25
- export interface WorkspaceProps {
24
+ /** Props for `<Trial>`. */
25
+ export interface TrialProps {
26
26
  id: string;
27
27
  }
28
28
 
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) {
29
+ /** Renders one trial from the lab store: looks up its record and
30
+ * instrument, and mounts the instrument inside the trial chrome. */
31
+ export function Trial({ id }: TrialProps) {
32
32
  const lab = useLabContext();
33
33
  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));
34
+ if (!storeCtx) throw new Error('[labkit] <Trial> requires <LabStoreProvider>');
35
+ const record = useStore(storeCtx.store, (s) => s.trials.find((w) => w.id === id));
36
36
  if (!record) {
37
- return <div className="lk-workspace lk-workspace--unknown">Workspace not found: {id}</div>;
37
+ return <div className="lk-trial lk-trial--unknown">Trial not found: {id}</div>;
38
38
  }
39
39
  const instrument = lab.instruments.find((i) => i.name === record.instrumentName);
40
40
  if (!instrument) {
41
41
  return (
42
- <div className="lk-workspace lk-workspace--unknown">
43
- Unknown instrument: {record.instrumentName}
44
- </div>
42
+ <div className="lk-trial lk-trial--unknown">Unknown instrument: {record.instrumentName}</div>
45
43
  );
46
44
  }
47
45
  return (
48
- <WorkspaceRuntime
46
+ <TrialRuntime
49
47
  record={record}
50
48
  instrument={instrument}
51
49
  store={storeCtx.store}
52
- isLast={lab.workspaces.length <= 1}
50
+ isLast={lab.trials.length <= 1}
53
51
  />
54
52
  );
55
53
  }
56
54
 
57
- interface WorkspaceRuntimeProps {
58
- record: WorkspaceRecord;
55
+ interface TrialRuntimeProps {
56
+ record: TrialRecord;
59
57
  instrument: Instrument;
60
58
  store: LabStore;
61
59
  isLast: boolean;
62
60
  }
63
61
 
64
- function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntimeProps) {
62
+ function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps) {
65
63
  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);
64
+ const updateTrialState = useStore(store, (s) => s.updateTrialState);
65
+ const updateTrialConfig = useStore(store, (s) => s.updateTrialConfig);
66
+ const updateTrialView = useStore(store, (s) => s.updateTrialView);
67
+ const updateTrialUndoStack = useStore(store, (s) => s.updateTrialUndoStack);
70
68
 
71
69
  const busRef = useRef<EventBus | null>(null);
72
70
  if (busRef.current === null) busRef.current = createEventBus();
@@ -81,34 +79,34 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
81
79
 
82
80
  const snapshotIfNeeded = (event: string): void => {
83
81
  if (!undoCap || !undoEvents.has(event)) return;
84
- const current = store.getState().workspaces.find((w) => w.id === record.id);
82
+ const current = store.getState().trials.find((w) => w.id === record.id);
85
83
  if (!current) return;
86
84
  const snap = structuredClone(current.state);
87
- updateWorkspaceUndoStack(record.id, (prev) => pushSnapshot(prev, snap, maxDepth));
85
+ updateTrialUndoStack(record.id, (prev) => pushSnapshot(prev, snap, maxDepth));
88
86
  };
89
87
 
90
- const setView = (v: ViewTransform): void => updateWorkspaceView(record.id, v);
88
+ const setView = (v: ViewTransform): void => updateTrialView(record.id, v);
91
89
 
92
90
  const renderCtx: RenderContext<unknown, unknown> = {
93
91
  state: record.state,
94
92
  config: record.config,
95
93
  setState: (next) => {
96
94
  snapshotIfNeeded('state.change');
97
- updateWorkspaceState(record.id, next);
95
+ updateTrialState(record.id, next);
98
96
  bus.emit('state.change');
99
97
  },
100
98
  setConfig: (key, value) => {
101
99
  const evt = `config.change:${String(key)}`;
102
100
  snapshotIfNeeded('config.change');
103
101
  snapshotIfNeeded(evt);
104
- updateWorkspaceConfig(record.id, key as never, value as never);
102
+ updateTrialConfig(record.id, key as never, value as never);
105
103
  bus.emit('config.change');
106
104
  bus.emit(evt);
107
105
  },
108
- workspace: {
106
+ trial: {
109
107
  id: record.id,
110
108
  zoom: record.view.zoom,
111
- setZoom: (z) => updateWorkspaceView(record.id, { ...record.view, zoom: z }),
109
+ setZoom: (z) => updateTrialView(record.id, { ...record.view, zoom: z }),
112
110
  },
113
111
  emit: (event) => {
114
112
  snapshotIfNeeded(event);
@@ -123,14 +121,14 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
123
121
  undo: () => {
124
122
  const result = undoUndo(record.undoStack, structuredClone(record.state));
125
123
  if (!result) return;
126
- updateWorkspaceState(record.id, result.snapshot as never);
127
- updateWorkspaceUndoStack(record.id, result.stack);
124
+ updateTrialState(record.id, result.snapshot as never);
125
+ updateTrialUndoStack(record.id, result.stack);
128
126
  },
129
127
  redo: () => {
130
128
  const result = undoRedo(record.undoStack, structuredClone(record.state));
131
129
  if (!result) return;
132
- updateWorkspaceState(record.id, result.snapshot as never);
133
- updateWorkspaceUndoStack(record.id, result.stack);
130
+ updateTrialState(record.id, result.snapshot as never);
131
+ updateTrialUndoStack(record.id, result.stack);
134
132
  },
135
133
  }
136
134
  : undefined;
@@ -144,8 +142,13 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
144
142
  return ordered.map((layer) => ({
145
143
  id: layer.id,
146
144
  visible: layerVisibility[layer.id] !== false,
147
- render: (ctx, view) =>
148
- layer.draw(ctx, { state: record.state, config: record.config, zoom: view.zoom }),
145
+ render: (ctx, view) => {
146
+ // Camera applied here, so a layer draws in world coordinates. `zoom`
147
+ // stays in the args for line widths, which must not scale with it.
148
+ ctx.translate(view.pan.x, view.pan.y);
149
+ ctx.scale(view.zoom, view.zoom);
150
+ layer.draw(ctx, { state: record.state, config: record.config, zoom: view.zoom });
151
+ },
149
152
  }));
150
153
  }, [instrument.canvas, record.state, record.config, layerVisibility, layerOrder]);
151
154
 
@@ -162,7 +165,7 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
162
165
  config: record.config,
163
166
  setState: (next) => {
164
167
  snapshotIfNeeded('canvas.itemAdded');
165
- updateWorkspaceState(record.id, next as never);
168
+ updateTrialState(record.id, next as never);
166
169
  },
167
170
  emit: (evt) => {
168
171
  snapshotIfNeeded(evt);
@@ -206,10 +209,12 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
206
209
  body = (
207
210
  <div
208
211
  ref={canvasContainerRef}
209
- className="lk-workspace__canvas-host"
212
+ className="lk-trial__canvas-host"
210
213
  style={{ width: '100%', height: '100%', position: 'relative' }}
211
214
  >
212
- <CanvasStack layers={layersWithFeedback} view={record.view} onViewChange={setView} />
215
+ <CanvasStack layers={layersWithFeedback} view={record.view} onViewChange={setView}>
216
+ {instrument.render(renderCtx)}
217
+ </CanvasStack>
213
218
  <DragOverlay drag={dragDropResult.drag} />
214
219
  </div>
215
220
  );
@@ -239,11 +244,11 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
239
244
  ) : null;
240
245
 
241
246
  return (
242
- <WorkspaceChrome
243
- workspaceId={record.id}
247
+ <TrialChrome
248
+ trialId={record.id}
244
249
  record={record}
245
250
  instrument={instrument}
246
- isLastWorkspace={isLast}
251
+ isLastTrial={isLast}
247
252
  undoBindings={undoBindings}
248
253
  sidebarExtras={
249
254
  <>
@@ -253,6 +258,6 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
253
258
  }
254
259
  >
255
260
  {body}
256
- </WorkspaceChrome>
261
+ </TrialChrome>
257
262
  );
258
263
  }