@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
@@ -0,0 +1,210 @@
1
+ import { act, render, waitFor } from '@testing-library/react';
2
+ import { useState } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import type { JobCapability, JobEvent, JobHandle } from './types';
5
+ import { useJob } from './useJob';
6
+
7
+ interface State {
8
+ items: number[];
9
+ }
10
+ interface Config {
11
+ n: number;
12
+ }
13
+
14
+ /** Yields 0..n-1, pausing between each so a test can cancel mid-run. */
15
+ async function* counter(
16
+ n: number,
17
+ signal: AbortSignal,
18
+ failAt?: number,
19
+ ): AsyncGenerator<JobEvent<number>> {
20
+ yield { kind: 'total', total: n };
21
+ for (let i = 0; i < n; i++) {
22
+ await new Promise((r) => setTimeout(r, 2));
23
+ if (signal.aborted) return;
24
+ if (i === failAt) {
25
+ yield { kind: 'failed', index: i, error: 'frame died' };
26
+ continue;
27
+ }
28
+ yield { kind: 'item', item: i };
29
+ }
30
+ }
31
+
32
+ const capability = (failAt?: number): JobCapability<State, Config, number> => ({
33
+ run: ({ config, signal }) => counter(config.n, signal, failAt),
34
+ onItem: (item, state) => ({ items: [...state.items, item] }),
35
+ });
36
+
37
+ interface HarnessProps {
38
+ capability: JobCapability<State, Config, number>;
39
+ config: Config;
40
+ onHandle: (h: JobHandle, s: State) => void;
41
+ }
42
+
43
+ function Harness({ capability: cap, config, onHandle }: HarnessProps) {
44
+ const [state, setState] = useState<State>({ items: [] });
45
+ const job = useJob({ capability: cap, config, state, setState });
46
+ onHandle(job, state);
47
+ return null;
48
+ }
49
+
50
+ describe('useJob', () => {
51
+ it('folds each item into state and counts progress', async () => {
52
+ const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
53
+ render(
54
+ <Harness
55
+ capability={capability()}
56
+ config={{ n: 3 }}
57
+ onHandle={(h, s) => {
58
+ seen.handle = h;
59
+ seen.state = s;
60
+ }}
61
+ />,
62
+ );
63
+ act(() => seen.handle?.start());
64
+ await waitFor(() => expect(seen.handle?.status).toBe('done'));
65
+ expect(seen.state.items).toEqual([0, 1, 2]);
66
+ expect(seen.handle?.done).toBe(3);
67
+ expect(seen.handle?.total).toBe(3);
68
+ });
69
+
70
+ it('counts a failed item without ending the run', async () => {
71
+ const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
72
+ render(
73
+ <Harness
74
+ capability={capability(1)}
75
+ config={{ n: 3 }}
76
+ onHandle={(h, s) => {
77
+ seen.handle = h;
78
+ seen.state = s;
79
+ }}
80
+ />,
81
+ );
82
+ act(() => seen.handle?.start());
83
+ await waitFor(() => expect(seen.handle?.status).toBe('done'));
84
+ expect(seen.state.items).toEqual([0, 2]);
85
+ expect(seen.handle?.failures).toEqual([{ index: 1, error: 'frame died' }]);
86
+ expect(seen.handle?.done).toBe(2);
87
+ });
88
+
89
+ it('stops folding results once cancelled', async () => {
90
+ const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
91
+ render(
92
+ <Harness
93
+ capability={capability()}
94
+ config={{ n: 50 }}
95
+ onHandle={(h, s) => {
96
+ seen.handle = h;
97
+ seen.state = s;
98
+ }}
99
+ />,
100
+ );
101
+ act(() => seen.handle?.start());
102
+ await waitFor(() => expect(seen.handle?.status).toBe('running'));
103
+ act(() => seen.handle?.cancel());
104
+ const atCancel = seen.state.items.length;
105
+ await new Promise((r) => setTimeout(r, 30));
106
+ expect(seen.state.items.length).toBe(atCancel);
107
+ expect(seen.handle?.status).toBe('idle');
108
+ });
109
+
110
+ it('discards results from a run its key superseded', async () => {
111
+ const withKey: JobCapability<State, Config, number> = {
112
+ ...capability(),
113
+ key: (config) => [config.n],
114
+ auto: true,
115
+ };
116
+ const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
117
+ const { rerender } = render(
118
+ <Harness
119
+ capability={withKey}
120
+ config={{ n: 40 }}
121
+ onHandle={(h, s) => {
122
+ seen.handle = h;
123
+ seen.state = s;
124
+ }}
125
+ />,
126
+ );
127
+ await waitFor(() => expect(seen.handle?.status).toBe('running'));
128
+
129
+ rerender(
130
+ <Harness
131
+ capability={withKey}
132
+ config={{ n: 2 }}
133
+ onHandle={(h, s) => {
134
+ seen.handle = h;
135
+ seen.state = s;
136
+ }}
137
+ />,
138
+ );
139
+ await waitFor(() => expect(seen.handle?.status).toBe('done'));
140
+
141
+ // The superseded 40-item run cannot have contributed: the winner yields two.
142
+ expect(seen.state.items).toEqual([0, 1]);
143
+ });
144
+
145
+ it('aborts on unmount', async () => {
146
+ const aborted = vi.fn();
147
+ const watching: JobCapability<State, Config, number> = {
148
+ run: ({ signal }) => {
149
+ signal.addEventListener('abort', aborted);
150
+ return counter(50, signal);
151
+ },
152
+ onItem: (item, state) => ({ items: [...state.items, item] }),
153
+ };
154
+ const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
155
+ const { unmount } = render(
156
+ <Harness
157
+ capability={watching}
158
+ config={{ n: 50 }}
159
+ onHandle={(h) => {
160
+ seen.handle = h;
161
+ }}
162
+ />,
163
+ );
164
+ act(() => seen.handle?.start());
165
+ await waitFor(() => expect(seen.handle?.status).toBe('running'));
166
+ unmount();
167
+ expect(aborted).toHaveBeenCalled();
168
+ });
169
+
170
+ it('reports a thrown error without losing the items already folded', async () => {
171
+ const throwing: JobCapability<State, Config, number> = {
172
+ run: async function* () {
173
+ yield { kind: 'item', item: 7 };
174
+ await new Promise((r) => setTimeout(r, 1));
175
+ throw new Error('the baker died');
176
+ },
177
+ onItem: (item, state) => ({ items: [...state.items, item] }),
178
+ };
179
+ const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
180
+ render(
181
+ <Harness
182
+ capability={throwing}
183
+ config={{ n: 1 }}
184
+ onHandle={(h, s) => {
185
+ seen.handle = h;
186
+ seen.state = s;
187
+ }}
188
+ />,
189
+ );
190
+ act(() => seen.handle?.start());
191
+ await waitFor(() => expect(seen.handle?.status).toBe('error'));
192
+ expect(seen.handle?.error).toMatch(/the baker died/);
193
+ expect(seen.state.items).toEqual([7]);
194
+ });
195
+
196
+ it('does not start on its own without auto', async () => {
197
+ const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
198
+ render(
199
+ <Harness
200
+ capability={{ ...capability(), key: (c) => [c.n] }}
201
+ config={{ n: 3 }}
202
+ onHandle={(h) => {
203
+ seen.handle = h;
204
+ }}
205
+ />,
206
+ );
207
+ await new Promise((r) => setTimeout(r, 20));
208
+ expect(seen.handle?.status).toBe('idle');
209
+ });
210
+ });
@@ -0,0 +1,134 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { JobCapability, JobFailure, JobHandle, JobStatus } from './types';
3
+
4
+ export interface UseJobOptions<TS, TC, TItem> {
5
+ capability: JobCapability<TS, TC, TItem>;
6
+ config: TC;
7
+ state: TS;
8
+ setState: (next: TS | ((prev: TS) => TS)) => void;
9
+ }
10
+
11
+ interface Progress {
12
+ status: JobStatus;
13
+ done: number;
14
+ total: number | null;
15
+ failures: JobFailure[];
16
+ error: string | null;
17
+ }
18
+
19
+ const IDLE: Progress = { status: 'idle', done: 0, total: null, failures: [], error: null };
20
+
21
+ function sameKey(a: readonly unknown[] | null, b: readonly unknown[] | null): boolean {
22
+ if (a === null || b === null) return a === b;
23
+ return a.length === b.length && a.every((v, i) => Object.is(v, b[i]));
24
+ }
25
+
26
+ export function useJob<TS, TC, TItem>({
27
+ capability,
28
+ config,
29
+ state,
30
+ setState,
31
+ }: UseJobOptions<TS, TC, TItem>): JobHandle {
32
+ const [progress, setProgress] = useState<Progress>(IDLE);
33
+
34
+ // A run is identified by a token. Every result checks its token before touching
35
+ // state, so a superseded run finishes harmlessly instead of racing the winner.
36
+ const token = useRef(0);
37
+ const abort = useRef<AbortController | null>(null);
38
+
39
+ // Read through refs: `run` is called once per run and must see the values as of
40
+ // that moment rather than re-subscribing on every render.
41
+ const capRef = useRef(capability);
42
+ capRef.current = capability;
43
+ const configRef = useRef(config);
44
+ configRef.current = config;
45
+ const stateRef = useRef(state);
46
+ stateRef.current = state;
47
+ const setStateRef = useRef(setState);
48
+ setStateRef.current = setState;
49
+
50
+ const cancel = useCallback(() => {
51
+ token.current += 1;
52
+ abort.current?.abort();
53
+ abort.current = null;
54
+ setProgress(IDLE);
55
+ }, []);
56
+
57
+ const start = useCallback(() => {
58
+ token.current += 1;
59
+ const mine = token.current;
60
+ abort.current?.abort();
61
+ const controller = new AbortController();
62
+ abort.current = controller;
63
+ setProgress({ ...IDLE, status: 'running' });
64
+
65
+ void (async () => {
66
+ try {
67
+ const iterable = capRef.current.run({
68
+ config: configRef.current,
69
+ state: stateRef.current,
70
+ signal: controller.signal,
71
+ });
72
+ for await (const event of iterable) {
73
+ if (token.current !== mine) return;
74
+ if (event.kind === 'total') {
75
+ setProgress((p) => ({ ...p, total: event.total }));
76
+ } else if (event.kind === 'failed') {
77
+ setProgress((p) => ({
78
+ ...p,
79
+ failures: [...p.failures, { index: event.index, error: event.error }],
80
+ }));
81
+ } else {
82
+ const fold = capRef.current.onItem;
83
+ const item = event.item;
84
+ setStateRef.current((prev) => fold(item, prev));
85
+ setProgress((p) => ({ ...p, done: p.done + 1 }));
86
+ }
87
+ }
88
+ if (token.current !== mine) return;
89
+ setProgress((p) => ({ ...p, status: 'done' }));
90
+ } catch (err) {
91
+ if (token.current !== mine) return;
92
+ setProgress((p) => ({
93
+ ...p,
94
+ status: 'error',
95
+ error: err instanceof Error ? err.message : String(err),
96
+ }));
97
+ }
98
+ })();
99
+ }, []);
100
+
101
+ // Re-run when the declared key changes. `auto` covers the first mount too.
102
+ const lastKey = useRef<readonly unknown[] | null>(null);
103
+ const started = useRef(false);
104
+ const auto = capability.auto === true;
105
+ const key = capability.key ? capability.key(config, state) : null;
106
+ // `key` is compared element-wise inside; depending on the array identity here
107
+ // would restart the job on every render.
108
+ useEffect(() => {
109
+ if (!auto) return;
110
+ if (started.current && sameKey(lastKey.current, key)) return;
111
+ lastKey.current = key;
112
+ started.current = true;
113
+ start();
114
+ }, [auto, key, start]); // eslint-disable-line react-hooks/exhaustive-deps
115
+
116
+ useEffect(
117
+ () => () => {
118
+ token.current += 1;
119
+ abort.current?.abort();
120
+ abort.current = null;
121
+ },
122
+ [],
123
+ );
124
+
125
+ return {
126
+ status: progress.status,
127
+ done: progress.done,
128
+ total: progress.total,
129
+ failures: progress.failures,
130
+ error: progress.error,
131
+ start,
132
+ cancel,
133
+ };
134
+ }
package/src/lab/Lab.less CHANGED
@@ -1,3 +1,19 @@
1
+ // `height: 100%` resolves against the containing block, so the lab needs an
2
+ // unbroken chain of sized ancestors — and a host that supplies the height but
3
+ // not the margin reset gets a page 16px taller than the viewport, which reads
4
+ // as a stuck canvas rather than as overflow. Scoped with `:has` so a page that
5
+ // mounts no lab is untouched, and stopping at the lab's own parent so an
6
+ // embedded lab cannot resize its host's layout.
7
+ html:has(.lk-lab),
8
+ body:has(.lk-lab) {
9
+ margin: 0;
10
+ height: 100%;
11
+ }
12
+
13
+ body:has(.lk-lab) > :has(> .lk-lab) {
14
+ height: 100%;
15
+ }
16
+
1
17
  .lk-lab {
2
18
  height: 100%;
3
19
  }
@@ -31,21 +31,20 @@ export const Default: Story = {
31
31
  },
32
32
  };
33
33
 
34
- function AddSecondWorkspace() {
34
+ function AddSecondTrial() {
35
35
  const ctx = useLabContext();
36
- // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount
37
36
  useEffect(() => {
38
- if (ctx.workspaces.length < 2) ctx.addWorkspace('Stub');
39
- }, []);
37
+ if (ctx.trials.length < 2) ctx.addTrial('Stub');
38
+ }, [ctx]);
40
39
  return null;
41
40
  }
42
41
 
43
- export const TwoWorkspaces: Story = {
42
+ export const TwoTrials: Story = {
44
43
  args: {
45
44
  instruments: [StubInstrument],
46
45
  defaultInstrument: 'Stub',
47
- title: 'Two Workspaces',
46
+ title: 'Two Trials',
48
47
  storage: null,
49
- children: <AddSecondWorkspace />,
48
+ children: <AddSecondTrial />,
50
49
  },
51
50
  };
@@ -41,50 +41,50 @@ function mountLab(props: Partial<Parameters<typeof Lab>[0]> = {}) {
41
41
  }
42
42
 
43
43
  describe('<Lab>', () => {
44
- it('renders one workspace by default', () => {
44
+ it('renders one trial by default', () => {
45
45
  mountLab();
46
- expect(screen.getAllByRole('region', { name: /workspace/i })).toHaveLength(1);
46
+ expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(1);
47
47
  });
48
48
 
49
- it('addWorkspace adds a second workspace', () => {
49
+ it('addTrial adds a second trial', () => {
50
50
  mountLab();
51
- act(() => labRef?.addWorkspace('Stub'));
52
- expect(screen.getAllByRole('region', { name: /workspace/i })).toHaveLength(2);
51
+ act(() => labRef?.addTrial('Stub'));
52
+ expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(2);
53
53
  });
54
54
 
55
- it('closeWorkspace removes one when more than one exists', () => {
55
+ it('closeTrial removes one when more than one exists', () => {
56
56
  mountLab();
57
- act(() => labRef?.addWorkspace('Stub'));
58
- const first = labRef?.workspaces[0];
59
- act(() => labRef?.closeWorkspace(first?.id ?? ''));
60
- expect(screen.getAllByRole('region', { name: /workspace/i })).toHaveLength(1);
57
+ act(() => labRef?.addTrial('Stub'));
58
+ const first = labRef?.trials[0];
59
+ act(() => labRef?.closeTrial(first?.id ?? ''));
60
+ expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(1);
61
61
  });
62
62
 
63
- it('closeWorkspace is a no-op on the last workspace', () => {
63
+ it('closeTrial is a no-op on the last trial', () => {
64
64
  mountLab();
65
- const only = labRef?.workspaces[0];
66
- act(() => labRef?.closeWorkspace(only?.id ?? ''));
67
- expect(screen.getAllByRole('region', { name: /workspace/i })).toHaveLength(1);
65
+ const only = labRef?.trials[0];
66
+ act(() => labRef?.closeTrial(only?.id ?? ''));
67
+ expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(1);
68
68
  });
69
69
 
70
- it('cloneWorkspace inserts immediately after source', () => {
70
+ it('cloneTrial inserts immediately after source', () => {
71
71
  mountLab();
72
- act(() => labRef?.addWorkspace('StubB'));
73
- const ws0 = labRef?.workspaces[0];
74
- const ws1 = labRef?.workspaces[1];
75
- act(() => labRef?.cloneWorkspace(ws0?.id ?? ''));
76
- const ids = labRef?.workspaces.map((w) => w.id) ?? [];
72
+ act(() => labRef?.addTrial('StubB'));
73
+ const ws0 = labRef?.trials[0];
74
+ const ws1 = labRef?.trials[1];
75
+ act(() => labRef?.cloneTrial(ws0?.id ?? ''));
76
+ const ids = labRef?.trials.map((w) => w.id) ?? [];
77
77
  expect(ids).toHaveLength(3);
78
78
  expect(ids[0]).toBe(ws0?.id);
79
79
  expect(ids[2]).toBe(ws1?.id);
80
80
  });
81
81
 
82
- it('resetWorkspace restores defaults', () => {
82
+ it('resetTrial restores defaults', () => {
83
83
  mountLab();
84
- const ws = labRef?.workspaces[0];
85
- if (!ws) throw new Error('no workspace');
86
- act(() => labRef?.resetWorkspace(ws.id));
87
- const reset = labRef?.workspaces[0];
84
+ const ws = labRef?.trials[0];
85
+ if (!ws) throw new Error('no trial');
86
+ act(() => labRef?.resetTrial(ws.id));
87
+ const reset = labRef?.trials[0];
88
88
  expect(reset?.config).toEqual({ count: 0 });
89
89
  expect(reset?.state).toEqual({ value: 0 });
90
90
  });
package/src/lab/Lab.tsx CHANGED
@@ -1,28 +1,28 @@
1
1
  import { ThemeProvider } from '@weasel-js/theme/react';
2
2
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
3
3
  import { useStore } from 'zustand/react';
4
- import type { Instrument } from '../instrument/types';
4
+ import type { InstrumentList } from '../instrument/types';
5
5
  import { noneAdapter } from '../state/adapters';
6
6
  import { LabStoreContext } from '../state/context';
7
7
  import { createLabStore, type LabStore } from '../state/store';
8
- import type { LabMode, StorageAdapter, WorkspaceRecord } from '../state/types';
8
+ import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
9
9
  import { interstellarTheme } from '../theme/interstellar';
10
- import { Workspace } from '../workspace/Workspace';
10
+ import { Trial } from '../trial/Trial';
11
11
  import {
12
- addWorkspace as addWorkspaceOp,
13
- cloneWorkspace as cloneWorkspaceOp,
14
- closeWorkspace as closeWorkspaceOp,
15
- reorderWorkspaces as reorderWorkspacesOp,
16
- resetWorkspace as resetWorkspaceOp,
17
- } from '../workspace/workspaceOps';
12
+ addTrial as addTrialOp,
13
+ cloneTrial as cloneTrialOp,
14
+ closeTrial as closeTrialOp,
15
+ reorderTrials as reorderTrialsOp,
16
+ resetTrial as resetTrialOp,
17
+ } from '../trial/trialOps';
18
18
  import { LabContext, type LabContextValue } from './LabContext';
19
19
  import { LabShell } from './LabShell';
20
20
  import { useResolvedMode } from './useSystemMode';
21
- import { WorkspaceGrid, type WorkspaceLayout } from './WorkspaceGrid';
21
+ import { type TrialLayout, Workspace } from './Workspace';
22
22
 
23
23
  /** Props for `<Lab>`. */
24
24
  export interface LabProps {
25
- instruments: Instrument[];
25
+ instruments: InstrumentList;
26
26
  defaultInstrument: string;
27
27
  storage?: StorageAdapter | null;
28
28
  storageKey?: string;
@@ -39,19 +39,19 @@ export interface LabProps {
39
39
  }
40
40
 
41
41
  function buildStore(
42
- instruments: Instrument[],
42
+ instruments: InstrumentList,
43
43
  defaultInstrument: string,
44
44
  storage: StorageAdapter,
45
45
  storageKey: string,
46
46
  initialMode: LabMode,
47
47
  ): LabStore {
48
48
  const store = createLabStore({ storageKey, storage, initialMode });
49
- if (store.getState().workspaces.length === 0) {
50
- const seeded = addWorkspaceOp([], instruments, defaultInstrument);
49
+ if (store.getState().trials.length === 0) {
50
+ const seeded = addTrialOp([], instruments, defaultInstrument);
51
51
  const record = seeded[0];
52
52
  if (record) {
53
53
  const { undoStack: _undoStack, ...rest } = record;
54
- store.getState().addWorkspace(rest);
54
+ store.getState().addTrial(rest);
55
55
  }
56
56
  }
57
57
  return store;
@@ -71,15 +71,15 @@ const NEBULA_SLOTS = [
71
71
 
72
72
  function buildNebula(colors: readonly string[]): string {
73
73
  const blobs = colors.map((c, i) => {
74
- const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length]!;
74
+ const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length];
75
75
  return `radial-gradient(ellipse ${p.sx} ${p.sy} at ${p.cx} ${p.cy}, color-mix(in srgb, ${c} 22%, transparent), transparent ${p.stop})`;
76
76
  });
77
77
  blobs.push('radial-gradient(ellipse at center, #0a0a18 0%, #02020a 100%)');
78
78
  return blobs.join(', ');
79
79
  }
80
80
 
81
- /** The lab runtime: creates the store, provides it, and renders one workspace
82
- * per record in a grid. Each workspace runs one of `instruments`. */
81
+ /** The lab runtime: creates the store, provides it, and renders one trial
82
+ * per record in a grid. Each trial runs one of `instruments`. */
83
83
  export function Lab({
84
84
  instruments,
85
85
  defaultInstrument,
@@ -106,7 +106,7 @@ export function Lab({
106
106
  }
107
107
  const store = storeRef.current;
108
108
 
109
- const workspaces = useStore(store, (s) => s.workspaces);
109
+ const trials = useStore(store, (s) => s.trials);
110
110
  const savedSnapshots = useStore(store, (s) => s.savedSnapshots);
111
111
  const modeValue = useStore(store, (s) => s.mode);
112
112
  const layout = useStore(store, (s) => s.layout);
@@ -119,51 +119,51 @@ export function Lab({
119
119
  }, [mode, store]);
120
120
 
121
121
  const contextValue = useMemo<LabContextValue>(() => {
122
- const replaceWorkspaces = (next: WorkspaceRecord[]): void => {
123
- const currentById = new Map(store.getState().workspaces.map((w) => [w.id, w]));
122
+ const replaceTrials = (next: TrialRecord[]): void => {
123
+ const currentById = new Map(store.getState().trials.map((w) => [w.id, w]));
124
124
  const merged = next.map((w) => currentById.get(w.id) ?? w);
125
- store.setState({ workspaces: merged });
125
+ store.setState({ trials: merged });
126
126
  // Trigger persistence flush via a tracked action.
127
127
  store.getState().setMode(store.getState().mode);
128
128
  };
129
129
 
130
130
  return {
131
131
  instruments,
132
- workspaces,
133
- addWorkspace: (instrumentName) => {
134
- const next = addWorkspaceOp(store.getState().workspaces, instruments, instrumentName);
135
- replaceWorkspaces(next);
132
+ trials,
133
+ addTrial: (instrumentName) => {
134
+ const next = addTrialOp(store.getState().trials, instruments, instrumentName);
135
+ replaceTrials(next);
136
136
  },
137
- cloneWorkspace: (id) => {
138
- const next = cloneWorkspaceOp(store.getState().workspaces, id);
139
- replaceWorkspaces(next);
137
+ cloneTrial: (id) => {
138
+ const next = cloneTrialOp(store.getState().trials, id);
139
+ replaceTrials(next);
140
140
  },
141
- closeWorkspace: (id) => {
142
- const next = closeWorkspaceOp(store.getState().workspaces, id);
143
- replaceWorkspaces(next);
141
+ closeTrial: (id) => {
142
+ const next = closeTrialOp(store.getState().trials, id);
143
+ replaceTrials(next);
144
144
  },
145
- reorderWorkspaces: (ids) => {
146
- replaceWorkspaces(reorderWorkspacesOp(store.getState().workspaces, ids));
145
+ reorderTrials: (ids) => {
146
+ replaceTrials(reorderTrialsOp(store.getState().trials, ids));
147
147
  },
148
- resetWorkspace: (id) => {
149
- const next = resetWorkspaceOp(store.getState().workspaces, id, instruments);
148
+ resetTrial: (id) => {
149
+ const next = resetTrialOp(store.getState().trials, id, instruments);
150
150
  const record = next.find((w) => w.id === id);
151
151
  if (!record) return;
152
152
  store.setState((s) => ({
153
- workspaces: s.workspaces.map((w) =>
153
+ trials: s.trials.map((w) =>
154
154
  w.id === id
155
155
  ? { ...w, config: record.config, state: record.state, view: record.view }
156
156
  : w,
157
157
  ),
158
158
  }));
159
- store.getState().updateWorkspaceView(id, record.view);
159
+ store.getState().updateTrialView(id, record.view);
160
160
  },
161
161
  savedSnapshots,
162
- saveSnapshot: (workspaceId, name) => {
163
- store.getState().saveSnapshot(workspaceId, name ?? `Save ${new Date().toLocaleString()}`);
162
+ saveSnapshot: (trialId, name) => {
163
+ store.getState().saveSnapshot(trialId, name ?? `Save ${new Date().toLocaleString()}`);
164
164
  },
165
- loadSnapshot: (workspaceId, snapshotId) => {
166
- store.getState().loadSnapshot(snapshotId, workspaceId);
165
+ loadSnapshot: (trialId, snapshotId) => {
166
+ store.getState().loadSnapshot(snapshotId, trialId);
167
167
  },
168
168
  deleteSnapshot: (snapshotId) => {
169
169
  store.getState().deleteSnapshot(snapshotId);
@@ -173,7 +173,7 @@ export function Lab({
173
173
  store.getState().setMode(m);
174
174
  },
175
175
  };
176
- }, [instruments, workspaces, savedSnapshots, modeValue, store]);
176
+ }, [instruments, trials, savedSnapshots, modeValue, store]);
177
177
 
178
178
  // Only override the backdrop in dark, where there is one to override.
179
179
  // Setting a CSS custom property is the sanctioned use of inline style.
@@ -192,18 +192,18 @@ export function Lab({
192
192
  style={backdropStyle}
193
193
  >
194
194
  <LabShell title={title ?? 'Labkit'} mode={modeValue} header={children}>
195
- <WorkspaceGrid
196
- ids={workspaces.map((w) => w.id)}
195
+ <Workspace
196
+ ids={trials.map((w) => w.id)}
197
197
  resizable
198
198
  reorderable
199
- onReorder={(ids) => contextValue.reorderWorkspaces(ids)}
200
- layout={layout as WorkspaceLayout}
199
+ onReorder={(ids) => contextValue.reorderTrials(ids)}
200
+ layout={layout as TrialLayout}
201
201
  onLayoutChange={(next) => store.getState().setLayout(next)}
202
202
  >
203
- {workspaces.map((w) => (
204
- <Workspace key={w.id} id={w.id} />
203
+ {trials.map((w) => (
204
+ <Trial key={w.id} id={w.id} />
205
205
  ))}
206
- </WorkspaceGrid>
206
+ </Workspace>
207
207
  </LabShell>
208
208
  </ThemeProvider>
209
209
  </LabContext.Provider>